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:useEntityDefinitionsanduseEntityRecords. The old v2 hooks (useEntities,useEntityAggregation,useEntityRelationships) are gone; aggregation/relationships are now methods onuseEntityRecords.
⛔ 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/entity → dist/**/*.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:
import { useEntityDefinitions, useEntityRecords } from "@elqnt/entity/hooks"— the only two hooks.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.import type { … } from "@elqnt/entity/models"—EntityRecord,EntityDefinition, etc. (the DTOs).import type { QueryRecordsParams, PaginatedResult, BulkResult } from "@elqnt/entity/hooks"— the query input/output + bulk result.
Rules (do not drift):
- 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. - Let the compiler check you. Build with
tsc; neveras any/@ts-ignoreyour way around a hook's types to force a call that isn't there. - Never bypass the hooks. No
fetch/axiosto/api/v1/entities/..., no NATS, no direct service calls. (Server-side only:@elqnt/entity/api+createServerClient, same signatures.) - Wrap, don't scatter. The hook is imported in exactly one app file
(
hooks/use-<domain>.ts); see "The domain layer". - 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/entityhooks (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:
| Layer | Where (your app) | Role |
|---|---|---|
| Components | components/<domain>/* | render; consume the context; see only the domain type |
| Context | contexts/<domain>-context.tsx | one shared app-hook instance for the subtree |
| App hook | hooks/use-<domain>.ts | CRUD + state; the only file importing @elqnt/entity; calls translators |
| Translators | lib/translators.ts | EntityRecord.fields ⇄ domain type (snake_case ⇄ camelCase) |
| Domain type | models/<domain>.ts | the camelCase business type (Publication) |
| Config | contexts/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 → browserApiRequest → getGatewayToken() 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-Productfrom the signed JWT claims before proxying, so a forged header can't override the token — the JWT is authoritative. Routes declare requiredscopes(e.g.write:entities); the check is an OR match, andadmin/super_adminrole 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 orcreateServerClientdirectly — not the hooks.
Env vars
| Var | Used by | Purpose |
|---|---|---|
JWT_SECRET | /api/gateway-token route + createServerClient | sign the gateway JWT (same value the gateway validates with) |
API_GATEWAY_URL_INTERNAL | createServerClient gatewayUrl | in-cluster gateway URL (server) |
API_GATEWAY_URL_PUBLIC | SSR layout → AppConfigProvider → browser baseUrl | public gateway URL, read at request time (not NEXT_PUBLIC_*) |
ORG_ID | token route / app config | the org all requests are scoped to |
Headers the API layer sets per request
| From hook option | Header |
|---|---|
| auto token | Authorization: Bearer <token> |
orgId | X-Org-ID |
userId | X-User-ID |
userEmail | X-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
useApiAsyncand returns anexecutefunction. There is nodata/auto-loadingper call — youawait queryRecords(...)to get data; the hook exposes aggregateloadinganderrorflags. On failure a method returns its default ([],null,"",false,0) and setserror— it does not throw.
Hook: useEntityDefinitions
import { useEntityDefinitions } from "@elqnt/entity/hooks";
const defs = useEntityDefinitions({ baseUrl, orgId, product: "my-product" });
Returns { loading, error, ...methods }:
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
listDefinitions | (params?: ListDefinitionsParams) => Promise<EntityDefinition[]> | [] on error | GET /api/v1/entities/definitions |
getDefinition | (entityName: string) => Promise<EntityDefinition | null> | null | GET /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/false | PUT /api/v1/entities/definitions/{entityName} |
deleteDefinition | (entityName: string, params?: DeleteDefinitionParams) => Promise<boolean> | true/false | DELETE /api/v1/entities/definitions/{entityName} |
listViews | (entityName: string) => Promise<EntityView[]> | [] | GET .../definitions/{entityName}/views |
createView | (entityName: string, view: Partial<EntityView>) => Promise<string> | new id | POST .../definitions/{entityName}/views |
updateView | (entityName: string, viewId: string, view: Partial<EntityView>) => Promise<boolean> | bool | PUT .../views/{viewId} |
deleteView | (entityName: string, viewId: string) => Promise<boolean> | bool | DELETE .../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 }:
| Method | Signature | Resolves to | Endpoint |
|---|---|---|---|
queryRecords | (params?: QueryRecordsParams) => Promise<PaginatedResult<EntityRecord>> | paginated result | POST /api/v1/entities/{entityName}/records/query |
getRecord | (recordId: string) => Promise<EntityRecord | null> | null | GET .../records/{recordId} |
createRecord | (record: Partial<EntityRecord>) => Promise<EntityRecord | null> | created record | POST .../records |
updateRecord | (recordId: string, record: Partial<EntityRecord>) => Promise<boolean> | bool | PUT .../records/{recordId} |
deleteRecord | (recordId: string) => Promise<boolean> | bool | DELETE .../records/{recordId} |
countRecords | (filters?: Record<string, unknown>) => Promise<number> | count | POST .../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> | rel | POST .../records/{recordId}/relationships |
listRelationships | (recordId: string, params?: ListRelationshipsParams) => Promise<EntityRelationship[]> | [] | GET .../records/{recordId}/relationships |
deleteRelationship | (recordId: string, relationshipId: string) => Promise<boolean> | bool | DELETE .../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):sortOrderis the number1(asc) /-1(desc). - Pagination —
page(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:day → created_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.tsis 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 aPublicationdomain type, translators that unwrap the stored record, ausePublicationshook, and a config/domain context thepublication/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, neverEntityRecord. 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/entityNameand 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.ts — EntityRecord.fields ⇄ Publication
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. OnlyuseEntityDefinitions+useEntityRecords. Don't importuseEntities/useEntityAggregation/useEntityRelationships— they don't exist. - Methods don't throw. They return defaults and set
error. Checkerror/ null results; don't wrap in try/catch expecting throws. - The package has no provider.
@elqnt/entityoptions go into every hook call — which is exactly why your app buildsAppConfigProvider(injectbaseUrl/orgIdonce 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. productmatters. The gateway resolves product from the JWT claim first (server) andX-Product(browser). Mismatched product = wrong DB.fieldsmust match the definition's JSON Schema or the create/update is rejected by the backend validator.$similarneeds embeddings. The entity definition must haveembeddingsConfig.enabled = true(and the rightfields) for semantic search to return anything.