Eloquent

Documentation

<!-- AUTO-GENERATED from packages/@elqnt/entity/SKILL.md — do not edit here. Run `pnpm docs:sync`. -->
npm install @elqnt/entity

Entities — Building a Custom Frontend

Eloquent's entities service is a dynamic, schema-driven record store (JSON Schema definitions + JSONB records, full-text + pgvector search, aggregations, relationships). This skill is only about one thing: building a custom app that drives it through the @elqnt/entity package.

Package is @elqnt/entity (singular). There is no @elqnt/entities. This is v3 — it exports exactly two hooks: useEntityDefinitions and useEntityRecords. The old v2 hooks (useEntities, useEntityAggregation, useEntityRelationships) are gone; aggregation/relationships are now methods on useEntityRecords.


⛔ The contract — read this before writing any code

This skill ships inside the package, so the contract is not a separate file to keep in sync — it is the package's own published type declarations (@elqnt/entitydist/**/*.d.ts). Because your app imports the real hooks, the TypeScript compiler enforces the contract for you: a wrong param or a misused return value won't compile.

The single source of truth, in order:

  1. import { useEntityDefinitions, useEntityRecords } from "@elqnt/entity/hooks" — the only two hooks.
  2. import type { UseEntityRecordsReturn, UseEntityDefinitionsReturn } from "@elqnt/entity/hooks" — the named, exact method surface of each hook (every method, its params, its return type). The tables below are a human-readable mirror of these.
  3. import type { … } from "@elqnt/entity/models"EntityRecord, EntityDefinition, etc. (the DTOs).
  4. import type { QueryRecordsParams, PaginatedResult, BulkResult } from "@elqnt/entity/hooks" — the query input/output + bulk result.

Rules (do not drift):

  1. Use only the two exported hooks and only the methods they expose. Do not invent hooks, methods, params, or return shapes — if it's not on ReturnType<typeof useEntityRecords> / …Definitions, it doesn't exist.
  2. Let the compiler check you. Build with tsc; never as any / @ts-ignore your way around a hook's types to force a call that isn't there.
  3. Never bypass the hooks. No fetch/axios to /api/v1/entities/..., no NATS, no direct service calls. (Server-side only: @elqnt/entity/api + createServerClient, same signatures.)
  4. Wrap, don't scatter. The hook is imported in exactly one app file (hooks/use-<domain>.ts); see "The domain layer".
  5. If a requirement seems to need a call the package doesn't define, stop and flag it — do not improvise an endpoint.

The prose/tables below explain each method; the package's shipped .d.ts is what your code type-checks against.


Architecture — the one and only path

A component never calls a package hook directly. It only sees your domain type (Publication), served by a context, backed by your app hook (usePublications), which is the only place that wraps the @elqnt/entity hook and runs the translators. Everything below the app hook is plumbing the component never imports.

┌──────────────────────────────────────────────────────────────────────────────┐
│  YOUR CUSTOM APP (Next.js / React)                                             │
│                                                                                │
│  SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID               │
│       └─► AppConfigProvider { apiGatewayUrl, orgId }                           │
│                                                                                │
│  components/publications/*   ── speak only the Publication domain type         │
│         │  usePublicationsContext()                                            │
│         ▼                                                                       │
│  contexts/publications-context.tsx   ── one shared usePublications() instance  │
│         │                                                                       │
│         ▼                                                                       │
│  hooks/use-publications.ts   ── app hook: CRUD + state, domain in / domain out │
│         │            │                                                          │
│         │            └─► lib/translators.ts  (EntityRecord.fields ⇄ Publication)│
│         │                models/publication.ts  (the camelCase domain type)    │
│         ▼                                                                       │
│  useEntityRecords / useEntityDefinitions   (@elqnt/entity/hooks)               │
│         │                                                                       │
│         ▼                                                                       │
│  @elqnt/entity/api  →  browserApiRequest                                       │
│         │  getGatewayToken() ⇒ GET /api/gateway-token (mint HS256 JWT, JWT_SECRET)│
│         │  attaches: Authorization: Bearer <jwt>, X-Org-ID, X-User-ID, X-Product│
└─────────┼──────────────────────────────────────────────────────────────────────┘
          ▼
   ┌─────────────────┐   verify JWT, stamp X-Org-ID/X-Product, route match
   │   API GATEWAY   │   /api/v1/entities/** → entities svc
   └───────┬─────────┘
           ▼
   ┌─────────────────┐   per-product Postgres entities_<product>
   │ entities (Go)   │   RLS by org_id, JSON-Schema validation
   └─────────────────┘

Rules: the frontend never calls the entities service directly and never touches NATS. Every call is HTTP through the gateway carrying an org id + gateway token. And components never import @elqnt/entity — only the app hook does.

Two ways to read this skill:

  • Reference (mid-document) — the exact spec of the @elqnt/entity hooks (useEntityRecords, useEntityDefinitions): every method, param, return type, endpoint. This is what your app hook wraps.
  • The required app structure ("The domain layer") — how a real app stacks models → translators → app hook → context → components on top of that reference. Build this, not bare hooks-in-components.

The layers, top to bottom:

LayerWhere (your app)Role
Componentscomponents/<domain>/*render; consume the context; see only the domain type
Contextcontexts/<domain>-context.tsxone shared app-hook instance for the subtree
App hookhooks/use-<domain>.tsCRUD + state; the only file importing @elqnt/entity; calls translators
Translatorslib/translators.tsEntityRecord.fields ⇄ domain type (snake_case ⇄ camelCase)
Domain typemodels/<domain>.tsthe camelCase business type (Publication)
Configcontexts/app-config-context.tsx{ apiGatewayUrl, orgId } from SSR
— package —@elqnt/entity/{hooks,api,models}the gateway client the app hook wraps

The gateway token (the secret) — how the custom app gets it

Every request to the gateway needs Authorization: Bearer <token>. The token is a short-lived HS256 JWT signed with the shared gateway secret. You never hardcode it; there are two flows depending on where the code runs.

Browser flow (what the hooks use)

The hooks → API fns → browserApiRequestgetGatewayToken() internally. You do not pass a token to the hook. getGatewayToken() (from @elqnt/api-client/browser) by default does:

fetch("/api/gateway-token")   ⇒  { token, expiresIn }

So your custom app must expose a /api/gateway-token route that mints the JWT server-side (the secret stays on the server, never reaches the browser). The client caches the token and refreshes ~5 min before expiry.

// app/api/gateway-token/route.ts  (Next.js route handler — server only)
import { NextResponse } from "next/server";
import * as jose from "jose";

export async function GET() {
  const secret = new TextEncoder().encode(process.env.JWT_SECRET!);  // SAME secret the gateway validates with
  const token = await new jose.SignJWT({
    org_id:  process.env.ORG_ID!,
    user_id: "system",
    email:   "system@my-app.com",
    role:    "system",
    scopes:  ["read:entities", "write:entities"],   // OR-matched against the route's required scopes
    product: "my-product",                          // gateway resolves product from THIS claim first
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setIssuer("eloquent-gateway")     // must match gateway JWT_ISSUER
    .setAudience("eloquent-api")       // must match gateway JWT_AUDIENCE
    .setExpirationTime("1h")
    .sign(secret);

  return NextResponse.json({ token, expiresIn: 3600 });
}

The gateway re-stamps X-Org-ID / X-User-ID / X-Product from the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. Routes declare required scopes (e.g. write:entities); the check is an OR match, and admin/super_admin role or a * scope bypasses it.

Override the token source (mobile/native, or a non-default URL) once at startup:

import { configureAuth } from "@elqnt/api-client/browser";
configureAuth(async () => myTokenProvider());     // URL string or async () => token|null
// clearGatewayTokenCache()  — call after switching orgs

Server flow (server actions / SSR)

@elqnt/api-client/server mints the JWT itself with jose — no /api/gateway-token hop. This is where the secret is injected directly:

import { createServerClient } from "@elqnt/api-client/server";

const client = createServerClient({
  gatewayUrl: process.env.API_GATEWAY_URL_INTERNAL!,  // in-cluster gateway URL (server-side)
  jwtSecret:  process.env.JWT_SECRET!,                // the shared gateway secret
  defaultProduct: "my-product",                        // REQUIRED: gateway reads product from the JWT claim first
  defaultScopes: ["read", "write", "admin"],
});

await client.post("/api/v1/entities/ticket/records/query", { query: {} }, { orgId, userId });

The entity hooks are browser-only ("use client"). For SSR/server actions call the API fns or createServerClient directly — not the hooks.

Env vars

VarUsed byPurpose
JWT_SECRET/api/gateway-token route + createServerClientsign the gateway JWT (same value the gateway validates with)
API_GATEWAY_URL_INTERNALcreateServerClient gatewayUrlin-cluster gateway URL (server)
API_GATEWAY_URL_PUBLICSSR layout → AppConfigProvider → browser baseUrlpublic gateway URL, read at request time (not NEXT_PUBLIC_*)
ORG_IDtoken route / app configthe org all requests are scoped to

Headers the API layer sets per request

From hook optionHeader
auto tokenAuthorization: Bearer <token>
orgIdX-Org-ID
userIdX-User-ID
userEmailX-User-Email
product (default "eloquent")X-Product

Hook options (both hooks)

There is no provider/context. You pass options into each hook call. Both extend ApiClientOptions:

interface ApiClientOptions {
  baseUrl: string;     // API Gateway base URL — required
  orgId: string;       // required → X-Org-ID
  userId?: string;     // → X-User-ID
  userEmail?: string;  // → X-User-Email
  product?: string;    // → X-Product, defaults to "eloquent"
  headers?: Record<string, string>;
}

type UseEntityDefinitionsOptions = ApiClientOptions;

interface UseEntityRecordsOptions extends ApiClientOptions {
  entityName: string;  // the entity to operate on — required
}

Imperative, not auto-fetching. Every method is built on useApiAsync and returns an execute function. There is no data/auto-loading per call — you await queryRecords(...) to get data; the hook exposes aggregate loading and error flags. On failure a method returns its default ([], null, "", false, 0) and sets error — it does not throw.


Hook: useEntityDefinitions

import { useEntityDefinitions } from "@elqnt/entity/hooks";

const defs = useEntityDefinitions({ baseUrl, orgId, product: "my-product" });

Returns { loading, error, ...methods }:

MethodSignatureResolves toEndpoint
listDefinitions(params?: ListDefinitionsParams) => Promise<EntityDefinition[]>[] on errorGET /api/v1/entities/definitions
getDefinition(entityName: string) => Promise<EntityDefinition | null>nullGET /api/v1/entities/definitions/{entityName}
createDefinition(definition: Partial<EntityDefinition>) => Promise<string>new id ("" on error)POST /api/v1/entities/definitions
updateDefinition(entityName: string, definition: Partial<EntityDefinition>) => Promise<boolean>true/falsePUT /api/v1/entities/definitions/{entityName}
deleteDefinition(entityName: string, params?: DeleteDefinitionParams) => Promise<boolean>true/falseDELETE /api/v1/entities/definitions/{entityName}
listViews(entityName: string) => Promise<EntityView[]>[]GET .../definitions/{entityName}/views
createView(entityName: string, view: Partial<EntityView>) => Promise<string>new idPOST .../definitions/{entityName}/views
updateView(entityName: string, viewId: string, view: Partial<EntityView>) => Promise<boolean>boolPUT .../views/{viewId}
deleteView(entityName: string, viewId: string) => Promise<boolean>boolDELETE .../views/{viewId}
interface ListDefinitionsParams { module?: string; status?: string; }       // → ?module=&status=
interface DeleteDefinitionParams { deleteRecords?: boolean; hardDelete?: boolean; }

Hook: useEntityRecords

import { useEntityRecords } from "@elqnt/entity/hooks";

const records = useEntityRecords({ baseUrl, orgId, product: "my-product", entityName: "ticket" });

Returns { loading, error, ...methods }:

MethodSignatureResolves toEndpoint
queryRecords(params?: QueryRecordsParams) => Promise<PaginatedResult<EntityRecord>>paginated resultPOST /api/v1/entities/{entityName}/records/query
getRecord(recordId: string) => Promise<EntityRecord | null>nullGET .../records/{recordId}
createRecord(record: Partial<EntityRecord>) => Promise<EntityRecord | null>created recordPOST .../records
updateRecord(recordId: string, record: Partial<EntityRecord>) => Promise<boolean>boolPUT .../records/{recordId}
deleteRecord(recordId: string) => Promise<boolean>boolDELETE .../records/{recordId}
countRecords(filters?: Record<string, unknown>) => Promise<number>countPOST .../records/count body { filters }
aggregateRecords(request: AggregateRequest) => Promise<AggregateResponse>{ groups, totals }POST .../records/aggregate
bulkCreate(records: Partial<EntityRecord>[]) => Promise<BulkResult>{ message, count }POST .../records/bulk/create body { records }
bulkUpdate(records: Partial<EntityRecord>[]) => Promise<BulkResult>{ message, count }POST .../records/bulk/update body { records }
bulkDelete(recordIds: string[]) => Promise<BulkResult>{ message, count }POST .../records/bulk/delete body { recordIds }
createRelationship(recordId: string, rel: CreateRelationshipRequest) => Promise<EntityRelationship | null>relPOST .../records/{recordId}/relationships
listRelationships(recordId: string, params?: ListRelationshipsParams) => Promise<EntityRelationship[]>[]GET .../records/{recordId}/relationships
deleteRelationship(recordId: string, relationshipId: string) => Promise<boolean>boolDELETE .../relationships/{relationshipId}

queryRecords input — QueryRecordsParams

interface QueryRecordsParams {
  page?: number;                       // default 1 (1-based)
  pageSize?: number;                   // default 20
  filters?: Record<string, unknown>;   // filter DSL — see below
  sortBy?: string;
  sortOrder?: "asc" | "desc";          // hook converts to 1 / -1 on the wire
  includeLookups?: LookupInclude[];    // resolve referenced records
  include?: string[];                  // fields to include
  exclude?: string[];                  // fields to exclude
}

The hook wraps the body as { query: { ...params, sortOrder: 1|-1 } }.

queryRecords output — PaginatedResult<EntityRecord>

interface PaginatedResult<T> {
  records: T[];
  totalCount: number;
  currentPage: number;
  pageSize: number;
  totalPages: number;
  hasNextPage: boolean;      // currentPage < totalPages
  hasPreviousPage: boolean;  // currentPage > 1
}
interface BulkResult { message: string; count: number; }

The filter DSL (filters)

filters is a flat object keyed by field name. Three forms:

// 1. Plain equality
{ status: "open" }

// 2. Operator object — keys are $-prefixed operators
{ priority:   { "$in": ["high", "urgent"] },
  created_at: { "$gte": "2024-01-01T00:00:00Z" } }

// 3. Special top-level pseudo-fields
{ "$search":  "login page error" }                    // full-text
{ "$similar": "user cannot access dashboard" }        // pgvector semantic; sets EntityRecord.distance

// Combine freely:
{ "$search": "payment", status: "open", priority: { "$in": ["high", "urgent"] } }

Operator vocabulary: $eq $ne $gt $gte $lt $lte $in $nin $contains $startsWith $endsWith $exists $empty $between $search $similar.

  • Sort — hook level: sortBy + sortOrder: "asc"|"desc". Raw DTO level (AggregateRequest, API params): sortOrder is the number 1 (asc) / -1 (desc).
  • Paginationpage (1-based) + pageSize; response carries the totals.

Aggregation

interface AggregateRequest {
  filters?: Record<string, any>;
  groupBy?: string[];
  aggregates: AggregateField[];        // REQUIRED
  having?: Record<string, any>;
  sortBy?: string;
  sortOrder?: number;                  // 1 / -1
  limit?: number;
}
interface AggregateField {
  field: string;
  function: string;   // "count" | "sum" | "avg" | "min" | "max" | "distinct_count" | "percentile"
  alias?: string;
  percentile?: number; // required by "percentile"; fraction (0.9) or percent (90)
}
interface AggregateResponse {
  groups: AggregateGroup[];
  totals?: Record<string, any>;
}
interface AggregateGroup {
  key: Record<string, any>;     // e.g. { category: "Books" }
  values: Record<string, any>;  // e.g. { total: 42, revenue: 1200 }
  count: number;
}
const { groups } = await records.aggregateRecords({
  groupBy: ["status"],
  aggregates: [{ field: "id", function: "count", alias: "total" }],
});

Date bucketing. Suffix a groupBy field with :hour|day|week|month|quarter|year to wrap it in date_trunc. Without it, grouping by a timestamp puts every row in its own group — no time series is possible. The alias folds the bucket in (created_at:daycreated_at_day), which is also what sortBy takes.

await records.aggregateRecords({
  groupBy: ["created_at:day"],
  aggregates: [
    { field: "*", function: "count" },
    { field: "resolutionSeconds", function: "percentile", percentile: 90, alias: "p_90" },
  ],
  limit: 400,
});

sum/avg/percentile over a record field route through NULLIF(expr,'')::numeric server-side — a single blank value used to fail the whole aggregate. percentile returns null (not 0) when nothing numeric matched; the two mean different things.


Hook: useEntityAnalytics

The dashboard-shaped layer over the aggregate endpoint. Use it instead of hand-rolling AggregateRequest objects — the six shapes below cover most dashboards, and they're generic over entities (a CRM pipeline and a ticket queue are the same shapes over different rows).

import { useEntityAnalytics } from "@elqnt/entity/hooks";

const { tiles, breakdown, matrix, timeseries, percentiles, total, loading, error }
  = useEntityAnalytics({ baseUrl, orgId, entityName: "help_desk_ticket" });

await tiles({ open: { status: { $nin: ["resolved","closed"] } }, urgent: { priority: "urgent" } });
// → { open: 46, urgent: 7 }   (one request per key — no conditional aggregates server-side)

await breakdown({ by: "serviceName", filters, limit: 10 });   // → [{ key, count }]
await matrix({ rows: "priority", cols: "status", filters });  // → [{ row, col, count }]
await timeseries({ dateField: "created_at", bucket: "day", filters }); // → [{ date, count }]
await percentiles({ field: "resolutionSeconds", p: [50, 90], filters }); // → { 50: …, 90: … }
await total({ aggregates: [...], filters });

Same contract as useEntityRecords — imperative, never throws, no auto-fetch on mount — with one deliberate difference:

loading and error are per-operation, not merged: loading.breakdown, error.percentiles, plus loading.any / error.first. A tab firing four of these concurrently can't drive per-card skeletons from one boolean, and a chart that failed should show its own error while the cards beside it render.

Destructure the methods; never put the hook's container object in a useCallback/useEffect dep list. The container changes identity whenever loading/error changes — twice per request — so a callback depending on it changes every load, re-fires the effect, and loops until the browser starts returning ERR_INSUFFICIENT_RESOURCES. The methods themselves are referentially stable. For a fetch effect, also keep a useRef guard keyed by whatever the user actually changed (tab, date range): dep hygiene alone is one bad dep away from an unbounded request flood.


Relationships

interface CreateRelationshipRequest {
  toEntityName: string;
  toRecordId: string;
  relationshipType: string;
  fields?: Record<string, any>;
}
interface ListRelationshipsParams {
  relationshipType?: string;
  targetEntity?: string;
  direction?: "outgoing" | "incoming" | "both";   // default both
}
interface EntityRelationship {
  id: string;
  fromEntityName: string; fromRecordId: string;
  toEntityName: string;   toRecordId: string;
  relationshipType: string;
  fields?: Record<string, any>;
  createdAt: string; isActive: boolean;
}

Core types (@elqnt/entity/models)

Canonical types come from models/entity.ts (tygo-generated, Postgres v2). models/entities.ts is the legacy Mongo shape and is not exported — ignore it.

interface EntityDefinition {
  id: string;
  name: string;
  displayName: string;
  module: string;
  description: string;
  schema: JSONSchema;        // JSON Schema for the record's `fields`
  defaultViewId: string;
  metadata?: Record<string, any>;
  createdAt: string; updatedAt: string;
  isActive: boolean;
  version: number;
  embeddingsConfig?: EmbeddingsConfig;
  // registry/provenance (read-only on per-org inserts):
  artifactVersion?: number; product?: string; source?: string;
  forkedFromVersion?: number; orgId?: string;
}

interface EntityRecord {
  id: string;
  entityId: string;
  name: string;
  fields: Record<string, any>;   // must validate against the definition's schema
  tags?: string[];
  metadata?: Record<string, any>;
  createdAt: string; updatedAt: string;
  isActive: boolean;
  version: number;
  distance?: number;             // cosine distance on $similar searches (lower = closer)
}

interface EmbeddingsConfig {
  enabled: boolean;
  fields: string[];   // fields feeding the embedding text (empty = name only)
  model?: string;     // default text-embedding-3-large
  dimensions?: number;
}

Field types (EntityFieldTypes): string, stringMultiline, text, int, float, bool, date, datetime, email, phone, url, dropdown, multiselect, lookup, multilookup, currency, file, image, json.


The raw hook (reference only — don't ship this shape)

This is the bare useEntityRecords hook, shown so the spec above is concrete. It's fine for a one-off spike, but a real app must not put the package hook + EntityRecord + fields["..."] in a component — wrap it in the domain layer below. Note baseUrl/orgId come from useAppConfig() (fed by SSR), never a NEXT_PUBLIC_* var.

"use client";
import { useEntityRecords } from "@elqnt/entity/hooks";
import { useEffect, useState } from "react";
import type { EntityRecord } from "@elqnt/entity/models";
import { useAppConfig } from "@/contexts/app-config-context";

export function TicketsList() {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const records = useEntityRecords({
    baseUrl: apiGatewayUrl,
    orgId,
    product: "my-product",
    entityName: "ticket",
  });

  const [rows, setRows] = useState<EntityRecord[]>([]);

  useEffect(() => {
    records.queryRecords({
      page: 1,
      pageSize: 20,
      filters: { status: { "$in": ["open", "in_progress"] } },
      sortBy: "created_at",
      sortOrder: "desc",
    }).then((res) => setRows(res.records));
  }, []); // eslint-disable-line

  async function createTicket() {
    const created = await records.createRecord({
      name: "Cannot log in",
      fields: { status: "open", priority: "high", description: "500 on login" },
    });
    if (created) setRows((r) => [created, ...r]);
  }

  if (records.error) return <p>Error: {records.error}</p>;
  return (
    <div>
      <button onClick={createTicket} disabled={records.loading}>New</button>
      <ul>{rows.map((t) => <li key={t.id}>{t.name}</li>)}</ul>
    </div>
  );
}

The example above is the raw way — fine for a throwaway page, but don't spread EntityRecord + fields["snake_case"] across a real app. Wrap it in a domain layer. That's the rest of this skill.


The domain layer — types → translators → hooks → contexts

This is the required structure for a real custom app. A raw EntityRecord is a generic envelope ({ id, name, fields: {...} }) with snake_case custom fields. Don't let that shape leak into your components. Instead each app defines its own business domain type and isolates entities behind these layers:

┌──────────────────────────────────────────────────────────────────────┐
│  components/publications/*   ← only ever see the Publication domain type│
│         │ usePublicationsContext()                                     │
│         ▼                                                              │
│  contexts/publications-context.tsx   ← one shared instance of the hook │
│         │ usePublications()                                            │
│         ▼                                                              │
│  hooks/use-publications.ts   ← CRUD + calls translators, no UI         │
│         │ useEntityRecords({ entityName: "publication" })              │
│         │ toPublication() / toRecordFields()                           │
│         ▼                                                              │
│  lib/translators.ts          ← EntityRecord.fields ⇄ Publication       │
│  models/publication.ts       ← the camelCase business type            │
└──────────────────────────────────────────────────────────────────────┘

Reference implementation: customers/dji/dji-platform/apps/digital-library (DJI Digital Library). It defines a Publication domain type, translators that unwrap the stored record, a usePublications hook, and a config/domain context the publication/ components consume. The example below ports that exact structure onto the entities backend.

Why bother:

  • One source of truth for the shape. Components import Publication, never EntityRecord. If the stored schema changes, you fix one translator, not 40 files.
  • camelCase domain, snake_case storage. Translators do the mapping in one place.
  • Components don't know about the gateway. They call context methods; the hook owns baseUrl/orgId/entityName and CRUD.
  • One hook instance per subtree. The context wraps usePublications() once so every component shares the same loading/error/data instead of re-fetching.

1. models/publication.ts — the domain type

A clean camelCase interface. No fields, no snake_case, no EntityRecord.

// models/publication.ts
export type PublicationType = "law" | "regulation" | "book" | "journal_article";
export type PublicationStatus = "draft" | "published" | "archived";

export interface Publication {
  id: string;
  title: string;
  titleAr: string;
  description: string;
  type: PublicationType;
  category: string;
  authors: string[];
  publisher: string;
  publicationDate: string;
  pages: number;
  price: number;
  isFree: boolean;
  isFeatured: boolean;
  status: PublicationStatus;
  tags: string[];
  createdAt?: string;
  updatedAt?: string;
}

2. lib/translators.tsEntityRecord.fieldsPublication

Two functions: unwrap a stored record into the domain type, and wrap the domain type back into the Partial<EntityRecord> the hook writes. Use small coercers so a missing/garbage field never throws.

// lib/translators.ts
import type { EntityRecord } from "@elqnt/entity/models";
import type { Publication, PublicationType, PublicationStatus } from "@/models/publication";

type Fields = Record<string, unknown>;
const str = (v: unknown): string => (typeof v === "string" ? v : "");
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const arr = (v: unknown): string[] =>
  Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];

// EntityRecord → Publication  (unwrap record.fields, snake_case → camelCase)
export function toPublication(rec: EntityRecord): Publication {
  const f = (rec.fields ?? {}) as Fields;
  return {
    id: rec.id,
    title: str(f.title_en) || rec.name,
    titleAr: str(f.title_ar) || rec.name,
    description: str(f.description),
    type: (str(f.type) || "book") as PublicationType,
    category: str(f.category),
    authors: arr(f.authors),
    publisher: str(f.publisher),
    publicationDate: str(f.publication_date),
    pages: num(f.pages),
    price: num(f.price),
    isFree: f.is_free !== false,
    isFeatured: !!f.is_featured,
    status: (str(f.status) || "published") as PublicationStatus,
    tags: arr(f.tags),
    createdAt: rec.createdAt,
    updatedAt: rec.updatedAt,
  };
}

export const toPublications = (recs: EntityRecord[]): Publication[] => recs.map(toPublication);

// Publication → Partial<EntityRecord>  (wrap back into name + fields for writes)
export function toRecordFields(pub: Partial<Publication>): Partial<EntityRecord> {
  const fields: Fields = {};
  if (pub.title !== undefined)       fields.title_en = pub.title;
  if (pub.titleAr !== undefined)     fields.title_ar = pub.titleAr;
  if (pub.description !== undefined) fields.description = pub.description;
  if (pub.type !== undefined)        fields.type = pub.type;
  if (pub.category !== undefined)    fields.category = pub.category;
  if (pub.authors !== undefined)     fields.authors = pub.authors;
  if (pub.publisher !== undefined)   fields.publisher = pub.publisher;
  if (pub.publicationDate !== undefined) fields.publication_date = pub.publicationDate;
  if (pub.pages !== undefined)       fields.pages = pub.pages;
  if (pub.price !== undefined)       fields.price = pub.price;
  if (pub.isFree !== undefined)      fields.is_free = pub.isFree;
  if (pub.isFeatured !== undefined)  fields.is_featured = pub.isFeatured;
  if (pub.status !== undefined)      fields.status = pub.status;
  if (pub.tags !== undefined)        fields.tags = pub.tags;
  return { name: pub.title, fields };   // `name` is the record's display name
}

Filter translation. Query filters are keyed by the stored field name (snake_case), not the domain key — so the hook maps camelCase → snake_case before calling queryRecords. Keep that map next to the translators.

3. contexts/app-config-context.tsx — gateway config (from DJI)

A tiny context that holds { apiGatewayUrl, orgId }, fed by SSR so the hook never reads env vars at the component level. This is the DJI app's actual config context, verbatim:

// contexts/app-config-context.tsx
"use client";
import { createContext, useContext, type ReactNode } from "react";

export interface AppConfig { apiGatewayUrl: string; orgId: string; }

const AppConfigContext = createContext<AppConfig | undefined>(undefined);

export function AppConfigProvider({ children, initialConfig }: {
  children: ReactNode; initialConfig: AppConfig;
}) {
  return <AppConfigContext.Provider value={initialConfig}>{children}</AppConfigContext.Provider>;
}

export function useAppConfig() {
  const ctx = useContext(AppConfigContext);
  if (!ctx) throw new Error("useAppConfig must be used within AppConfigProvider");
  return ctx;
}

Set it once in the layout. Read the gateway URL server-side at request time from API_GATEWAY_URL_PUBLIC and forward it through the provider — do not use a NEXT_PUBLIC_* var: those are inlined at build time, so the build-env URL would be baked into the bundle forever.

// app/[locale]/layout.tsx  (server component)
import { AppConfigProvider } from "@/contexts/app-config-context";

// Read at request time (no NEXT_PUBLIC_ — that bakes the build-env value in).
const apiGatewayUrl = process.env.API_GATEWAY_URL_PUBLIC || "http://localhost:8080";

export default async function Layout({ children }: { children: React.ReactNode }) {
  return (
    <AppConfigProvider initialConfig={{ apiGatewayUrl, orgId: process.env.ORG_ID! }}>
      {children}
    </AppConfigProvider>
  );
}

4. hooks/use-publications.ts — CRUD wrapper, domain in / domain out

usePublications wraps useEntityRecords, takes config from useAppConfig(), and every method returns Publication(s), never EntityRecord. This is the only file that imports @elqnt/entity and the translators.

// hooks/use-publications.ts
"use client";
import { useMemo } from "react";
import { useEntityRecords } from "@elqnt/entity/hooks";
import { useAppConfig } from "@/contexts/app-config-context";
import { toPublication, toPublications, toRecordFields } from "@/lib/translators";
import type { Publication } from "@/models/publication";

// camelCase domain key → stored snake_case field, for filters
const FILTER_MAP: Partial<Record<keyof Publication, string>> = {
  type: "type", category: "category", status: "status",
  isFeatured: "is_featured", isFree: "is_free",
};

export interface QueryOpts {
  filters?: Partial<Record<keyof Publication, unknown>>;
  page?: number;
  pageSize?: number;
}

export function usePublications() {
  const { apiGatewayUrl, orgId } = useAppConfig();
  const records = useEntityRecords({
    baseUrl: apiGatewayUrl,
    orgId,
    product: "dji",
    entityName: "publication",
  });

  return useMemo(() => ({
    loading: records.loading,
    error: records.error,

    async fetchPublications(opts?: QueryOpts) {
      const filters: Record<string, unknown> = {};
      for (const [k, v] of Object.entries(opts?.filters ?? {})) {
        if (v === undefined || v === null || v === "") continue;
        filters[FILTER_MAP[k as keyof Publication] ?? k] = v;
      }
      const res = await records.queryRecords({
        filters,
        page: opts?.page ?? 1,
        pageSize: opts?.pageSize ?? 20,
        sortBy: "created_at",
        sortOrder: "desc",
      });
      return {
        publications: toPublications(res.records),
        totalCount: res.totalCount,
        totalPages: res.totalPages,
      };
    },

    async fetchPublication(id: string): Promise<Publication | null> {
      const rec = await records.getRecord(id);
      return rec ? toPublication(rec) : null;
    },

    async createPublication(pub: Partial<Publication>): Promise<Publication | null> {
      const created = await records.createRecord(toRecordFields(pub));
      return created ? toPublication(created) : null;
    },

    async updatePublication(id: string, pub: Partial<Publication>): Promise<boolean> {
      return records.updateRecord(id, toRecordFields(pub));
    },

    async deletePublication(id: string): Promise<boolean> {
      return records.deleteRecord(id);
    },
  }), [records]);
}

export type UsePublicationsReturn = ReturnType<typeof usePublications>;

5. contexts/publications-context.tsx — one shared instance

Wrap the hook in a context so every component under publication/ shares the same instance (one loading/error/data state, not N re-fetches).

// contexts/publications-context.tsx
"use client";
import { createContext, useContext, type ReactNode } from "react";
import { usePublications, type UsePublicationsReturn } from "@/hooks/use-publications";

const PublicationsContext = createContext<UsePublicationsReturn | undefined>(undefined);

export function PublicationsProvider({ children }: { children: ReactNode }) {
  const value = usePublications();   // single instance for the whole subtree
  return <PublicationsContext.Provider value={value}>{children}</PublicationsContext.Provider>;
}

export function usePublicationsContext(): UsePublicationsReturn {
  const ctx = useContext(PublicationsContext);
  if (!ctx) throw new Error("usePublicationsContext must be used within PublicationsProvider");
  return ctx;
}

Mount it around the publications subtree (inside AppConfigProvider):

// app/[locale]/publication/layout.tsx
import { PublicationsProvider } from "@/contexts/publications-context";
export default function Layout({ children }: { children: React.ReactNode }) {
  return <PublicationsProvider>{children}</PublicationsProvider>;
}

6. Components consume the domain type only

// components/publications/publication-list.tsx
"use client";
import { useEffect, useState } from "react";
import { usePublicationsContext } from "@/contexts/publications-context";
import type { Publication } from "@/models/publication";

export function PublicationList() {
  const { fetchPublications, loading, error } = usePublicationsContext();
  const [items, setItems] = useState<Publication[]>([]);

  useEffect(() => {
    fetchPublications({ filters: { status: "published", isFeatured: true } })
      .then((r) => setItems(r.publications));
  }, [fetchPublications]);

  if (error) return <p>{error}</p>;
  return (
    <ul>
      {items.map((p) => (        /* p is a Publication — no fields[], no snake_case */
        <li key={p.id}>{p.title} — {p.authors.join(", ")}</li>
      ))}
    </ul>
  );
}

Folder layout (mirrors the DJI app)

apps/<your-app>/
  models/publication.ts            # domain type(s)
  lib/translators.ts               # toPublication / toRecordFields (+ filter map)
  contexts/app-config-context.tsx  # { apiGatewayUrl, orgId } from SSR
  contexts/publications-context.tsx# PublicationsProvider wrapping the hook
  hooks/use-publications.ts        # CRUD over useEntityRecords, domain in/out
  app/[locale]/publication/        # the route + its components
    layout.tsx                     # mounts PublicationsProvider
    [id]/page.tsx
  components/publications/*         # consume usePublicationsContext()

Rule of thumb: @elqnt/entity is imported in exactly one file (use-publications.ts). Everything above it speaks Publication. If you find EntityRecord or record.fields["..."] in a component, push it down into the translator.


Provisioning entity definitions (admin)

import { provisionEntitiesApi } from "@elqnt/entity/api";

await provisionEntitiesApi(definitions /* EntityDefinition[] */, { baseUrl, orgId });
// → POST /api/v1/admin/entities/update  body { definitions }
// → { created: number; updated: number; errors: string[] }

Gotchas

  • It's @elqnt/entity (singular), v3. Only useEntityDefinitions + useEntityRecords. Don't import useEntities/useEntityAggregation/ useEntityRelationships — they don't exist.
  • Methods don't throw. They return defaults and set error. Check error / null results; don't wrap in try/catch expecting throws.
  • The package has no provider. @elqnt/entity options go into every hook call — which is exactly why your app builds AppConfigProvider (inject baseUrl/orgId once via SSR) and the domain context, so components and the app hook never re-thread config by hand.
  • Components never import @elqnt/entity. It's wrapped in one app hook (use-<domain>.ts); components speak the domain type via the context.
  • product matters. The gateway resolves product from the JWT claim first (server) and X-Product (browser). Mismatched product = wrong DB.
  • fields must match the definition's JSON Schema or the create/update is rejected by the backend validator.
  • $similar needs embeddings. The entity definition must have embeddingsConfig.enabled = true (and the right fields) for semantic search to return anything.