@quadrokit/client
v0.3.23
Published
Typed 4D REST client and catalog code generator for QuadroKit
Downloads
5,252
Readme
@quadrokit/client
Typed 4D REST client: a small runtime (fetch with credentials: 'include', lists, entity sets, $attributes) and a code generator that turns a catalog JSON into a typed createClient and entity types.
Install
npm add @quadrokit/client
# or
bun add @quadrokit/clientThe package exposes:
@quadrokit/client— runtime + types (and anything your generated code needs).@quadrokit/client/runtime— low-level helpers if you build custom integrations.@quadrokit/client/rx— optional RxJS bridge forQuadroEventBus(installrxjsin your app).
Generate the client from your 4D catalog
Install adds the quadrokit-client binary (alias: quadrokit).
bunx quadrokit-client generate \
--url 'http://localhost:7080/rest/$catalog/$all' \
--out .quadrokit/generatedWhy $catalog/$all?
Use a URL that returns the full catalog (dataclasses with attributes). Plain /rest/$catalog often lists tables only; without attribute metadata, generated select paths and typings are incomplete. The generator warns if attributes are missing.
CLI options
| Option | Description |
|--------|-------------|
| --url | Catalog URL, or file:///absolute/path/to/catalog.json. If omitted: ${VITE_4D_ORIGIN}/rest/$catalog/$all (from env / .env), else http://127.0.0.1:7080/rest/$catalog/$all. |
| --out | Output directory (default: .quadrokit/generated). |
| --token | Authorization: Bearer … for protected catalog endpoints (generator HTTP only). |
| --access-key | Multipart accessKey to 4D’s login API; generator obtains 4DAdminSID and uses it for the catalog request. Requires an http(s) --url unless you pass --login-url. |
| --login-url | Full login URL (default: {catalog origin}/api/login). |
| -v / --verbose | Step-by-step logs on stderr. |
| --insecure-tls | Skip TLS verification (dev / self-signed HTTPS only). |
| --no-split-type-files | Emit a single types.gen.ts. Default: split typings — entities/<Class>.gen.ts, optional datastore.gen.ts, and types.gen.ts as the barrel. |
Environment variables (often via .env in the project root): VITE_4D_ORIGIN, QUADROKIT_ACCESS_KEY, QUADROKIT_LOGIN_URL, QUADROKIT_CATALOG_TOKEN, QUADROKIT_GENERATE_VERBOSE, QUADROKIT_INSECURE_TLS.
Generated files
| File | Purpose |
|------|---------|
| types.gen.ts | Barrel: QuadroClient typing; re-exports entity types and *Path aliases. |
| entities/<ClassName>.gen.ts | One file per exposed dataclass (default layout): export interface + export type <ClassName>Path (QuadroAttributePaths<…>). Omit with --no-split-type-files (single types.gen.ts). |
| datastore.gen.ts | When the catalog lists datastore REST methods: per-method Datastore_* aliases and QuadroDatastoreMethodFn. Omitted when there are no such exposed methods. Runtime catalog.gen.json stores path as ["$catalog", "<methodName>"] → /rest/$catalog/<methodName> when baseURL is /rest. |
| catalog.gen.json | Catalog runtime spec (dataclass layouts, methods, relations) consumed by @quadrokit/client/runtime — keeps client.gen.ts tiny. |
| client.gen.ts | Thin createClient(config) that wires QuadroHttp + buildQuadroClientFromCatalogSpec + catalog.gen.json. Config supports optional events (see Request lifecycle events). |
| _quadroFns.gen.ts | Shared helpers: ResolveOverride, QuadroDataClassFn, QuadroEntityFn, QuadroEntityCollectionFn, QuadroDatastoreFn (used by *.gen.ts and optional override files). |
| entities/<Class>.overrides.ts, datastore.overrides.ts | Optional stubs you edit to narrow method signatures (see Narrowing function signatures). Created if missing; preserved across regenerate when possible. |
| meta.json | __NAME, sessionCookieName hint for 4D session cookies. |
Point your app imports at the generated folder, for example:
import { createClient } from './.quadrokit/generated/client.gen.js'
import type { Agency } from './.quadrokit/generated/types.gen.js'Creating the client
import { createClient } from './.quadrokit/generated/client.gen.js'
const quadro = createClient({
/** Base URL of the REST root, e.g. `https://host:port/rest` or `/rest` behind a dev proxy */
baseURL: import.meta.env.VITE_4D_REST_URL ?? '/rest',
/** Optional: swap `fetch` (e.g. tests, Node with undici) */
fetchImpl: globalThis.fetch,
/** Optional: default headers on every request */
defaultHeaders: { Authorization: `Bearer ${token}` },
})Optional events: new QuadroEventBus() (from @quadrokit/client/runtime) enables request lifecycle events on every call through the client.
At runtime, requests use credentials: 'include' so session cookies (e.g. 4DSID_<datastore>) are sent when your app and 4D share the same site or proxy. See meta.json → sessionCookieName after generate.
JSON: 4D date strings
QuadroHttp.json() parses response bodies with a JSON reviver so common 4D date encodings become JavaScript Date values or null:
| String | Result |
|--------|--------|
| dd!mm!yyyy (e.g. 22!2!2026) | Local calendar Date |
| !!yyyy-mm-dd!! (e.g. !!2026-02-22!!) | Local calendar Date |
| 0!0!0 | null |
| !!0000-00-00!! | null |
Other strings are unchanged. If you parse JSON yourself, use parseQuadroJson or quadroJsonReviver from @quadrokit/client/runtime.
Request lifecycle events
Pass a QuadroEventBus from @quadrokit/client/runtime into createClient({ …, events }). The runtime emits a loading → success or loading → error sequence per logical operation (lists, get, delete, class functions, datastore paths, login, etc.).
import { createClient } from './.quadrokit/generated/client.gen.js'
import { QuadroEventBus } from '@quadrokit/client/runtime'
const events = new QuadroEventBus()
const quadro = createClient({
baseURL: '/rest',
events,
})
const unsub = events.subscribe((e) => {
if (e.type === 'loading') {
console.debug(e.context.operation, e.path, e.method)
}
if (e.type === 'success') {
console.debug(e.durationMs, e.body)
}
if (e.type === 'error') {
console.warn(e.status, e.message, e.error)
}
})
// later: unsub()You can also subscribe on the HTTP instance: quadro._http.subscribe(listener) returns an unsubscribe function.
Event shapes (QuadroClientEvent)
| Field | loading | success | error |
|-------|-----------|-----------|---------|
| type | 'loading' | 'success' | 'error' |
| operationId | Correlates one start → one outcome | same | same |
| context | QuadroRequestContext: operation (QuadroOperation string), optional className, methodName, entityKey, attributes | same | same |
| path | Request path | same | same |
| method | HTTP method | — | — |
| startedAt | Timestamp | — | — |
| durationMs | — | Elapsed | Elapsed |
| status | — | If HTTP response is raw Response | If thrown value has HTTP status (e.g. QuadroHttpError) |
| body | — | Result: parsed JSON, rows, void, or a small summary for raw Response | — |
| message / error | — | — | User-facing message + original error |
Operations (QuadroOperation)
Examples include: http.json, http.void, http.request, collection.list, collection.release, dataclass.get, dataclass.delete, function.dataclass, function.entity, function.entityCollection, datastore (catalog datastore methods emit context.methodName set to the catalog method name).
QuadroHttp helpers (advanced)
When events is set, json, void, and request accept an optional trailing QuadroRequestContext so emissions can be tagged. QuadroHttp also exposes:
rawRequest(path, init)— fetch without emitting (used inside multi-step flows).runWithEvents(context, path, method, fn)— run an async function with the same loading/success/error envelope as the built-in methods.eventsgetter — the bus instance, if any.
RxJS (@quadrokit/client/rx)
Install rxjs in your app, then bridge the bus to an observable:
import { quadroEventsObservable } from '@quadrokit/client/rx'
import { filter } from 'rxjs/operators'
const sub = quadroEventsObservable(events)
.pipe(filter((e) => e.type === 'error'))
.subscribe((e) => console.warn(e.message))rxjs is an optional peer dependency of @quadrokit/client; you only need it if you import @quadrokit/client/rx.
Dataclass API (generated)
For each exposed dataclass, the client exposes a namespace such as quadro.Agency, quadro.Reservation, etc.
all(options?) — full class as an async iterable “collection”
Walk entities with optional select, filter, orderby, paging, and 4D entity sets ($method=entityset) for efficient paging.
const col = quadro.Agency.all({
select: ['ID', 'name', 'department.name'] as const,
filter: 'name = "Paris"',
page: 1,
pageSize: 50,
signal: ac.signal,
})
for await (const agency of col) {
console.log(agency.name)
}
await col.release() // release server entity set when done (see 4D $method=release)Important options (see CollectionOptions in @quadrokit/client/runtime):
| Option | Role |
|--------|------|
| select | $attributes: scalar or relation paths ('manager.name'). |
| filter / orderby | OData-style list constraints. |
| page / pageSize | Client-side paging over the list / entity set. |
| maxItems | Stop iteration after N rows. |
| signal | AbortController — abort in-flight requests when unmounting or changing filters. |
| reuseEntitySet / onEntitySetReady | Reuse a cached entity-set URI across requests (see your app’s paging hooks). |
query(filter, options?)
Same shape as all, but with a filter string as the first argument (and optional params for :1, :2, … placeholders in the filter).
get(id, options?)
Load one entity by primary key; optional select for $attributes.
delete(id)
DELETE the entity.
Related entities on a row
Mapped rows get non-enumerable properties for related collections (e.g. agency.todayBookings) with a .list(options) that returns another collection handle for the child dataclass.
ORDA class functions (generated from catalog)
If your 4D project exposes methods in the REST catalog, the generator wires them by applyTo (see Calling class functions):
| Catalog applyTo | Where it appears in JS | REST shape (simplified) |
|-------------------|-------------------------|-------------------------|
| dataClass | quadro.Agency.agencyStats<R, A>(args, init?) — R = result, A = args tuple (defaults: unknown, readonly unknown[]) | POST /rest/{Class}/{function} with JSON array body |
| entityCollection | On all() / query() handles and on related { list, … } APIs | POST /rest/{Class}/{function} + optional selection, entitySet |
| entity | On each mapped entity row | POST /rest/{Class}({key})/{function} |
args: tuple typeA(second generic on dataclass functions; defaultreadonly unknown[]) — 4D receives a JSON array of parameters (scalars, entities, entity selections per 4D rules).init: optional{ method: 'GET' \| 'POST', unwrapResult?, signal?, … }— GET uses?$params=…for HTTP GET functions.unwrapResult: whentrue(default),{ "result": x }responses are unwrapped tox.
Entity-selection methods accept EntityCollectionMethodOptions:
selection:{ filter?, orderby?, select?, page?, pageSize? }→$filter,$orderby,$attributes,$skip,$top.entitySet: UUID or path segment so the URL includes…/$entityset/{uuid}.
Example:
const stats = await quadro.Agency.agencyStats<MyStatsRow, [string, string]>([from, to])
// or: agencyStats<MyStatsRow>([from, to]) when you only need to fix the result type
const col = quadro.Reservation.all({ pageSize: 20 })
// After the handle exists, e.g. from a hook or manual create:
await col.getFirst?.([], { selection: { filter: 'ID > 0', pageSize: 1 } })
await reservation.cancel<{ ok: boolean }>([], { signal: ac.signal })If the catalog does not list a method, it will not appear on the client — regenerate after changing 4D exposure.
Narrowing function signatures (override files)
Generated ORDA dataClass, entity, and entityCollection methods, plus datastore methods, default to loose generics (unknown / readonly unknown[] for results and argument tuples). To type return values and parameters in TypeScript, add entries to the optional override modules beside the codegen output. The generator wires each method type as ResolveOverride<YourOverrides, "methodName", DefaultFn> in *.gen.ts: if your interface defines that exact property name (same spelling as the catalog / JS API), your signature wins; otherwise the default Quadro*Fn stands.
Where the files live
Split typings (default): for each exposed dataclass, entities/<ClassName>.overrides.ts declares up to three interfaces:
| Interface | Catalog applyTo | Example call site |
|-----------|-------------------|-------------------|
| <ClassName>DataclassOverrides | dataClass | quadro.Car.findACar(...) |
| <ClassName>EntityOverrides | entity | (await quadro.Car.get(id)).isAvailable(...) |
| <ClassName>EntityCollectionOverrides | entityCollection | On all() / query() handles and related list APIs |
datastore.overrides.ts (next to types.gen.ts) maps top-level datastore methods on quadro by catalog method name.
Single types.gen.ts (--no-split-type-files): same idea, with ./<ClassName>.overrides.ts and ./datastore.overrides.ts next to types.gen.ts.
Stub files may re-export QuadroDataClassFn, QuadroEntityFn, QuadroEntityCollectionFn, QuadroDatastoreFn from _quadroFns.gen.ts; you can import type those helpers from _quadroFns.gen.js (or .ts) when you add properties.
Fn type parameters (from _quadroFns.gen.ts)
QuadroDataClassFn<R, A>—R: resolvedPromiseresult type;A: tuple of positional arguments (sent as the JSON array body to 4D).QuadroEntityFn<R, A>— same shape for per-entity calls.QuadroEntityCollectionFn<R, A>— sameR/A; real calls also acceptinitwithEntityCollectionMethodOptions(selection,entitySet, …).QuadroDatastoreFn<B, R>—B: body type;R: result type forquadro.<datastoreMethod>(body?).
Examples
Per-class overrides (entities/Car.overrides.ts in the split layout):
import type { QuadroDataClassFn, QuadroEntityFn } from '../_quadroFns.gen.js'
import type { Car } from './Car.gen.js'
export interface CarDataclassOverrides {
/** Catalog method name must match exactly (e.g. findACar). */
findACar: QuadroDataClassFn<Car[], [string, string]>
}
export interface CarEntityOverrides {
isAvailable: QuadroEntityFn<boolean, readonly []>
}
export interface CarEntityCollectionOverrides {}Datastore overrides (datastore.overrides.ts):
import type { QuadroDatastoreFn } from './_quadroFns.gen.js'
export interface DatastoreOverrides {
testFn: QuadroDatastoreFn<{ foo: string }, { ok: true }>
}Regenerate behavior
Run quadrokit-client generate after catalog changes. The tool rewrites *.gen.ts (and related generated files) but keeps your existing *.overrides.ts content when refreshing entities/ (and only creates stub override files if missing). Do not rely on editing *.gen.ts — put signature narrowing in override modules only.
Low-level runtime (advanced)
Import from @quadrokit/client/runtime when you need primitives without codegen:
| Export | Use |
|--------|-----|
| QuadroHttp | Thin wrapper: json(), void(), request() with base URL + default headers; optional events; rawRequest, runWithEvents, subscribe. |
| QuadroEventBus, QuadroClientEvent, QuadroLoadingEvent, QuadroSuccessEvent, QuadroErrorEvent, QuadroRequestContext, QuadroOperation | Lifecycle event types and bus (see Request lifecycle events). |
| createCollection | Build a CollectionHandle from a CollectionContext + options + row mapper. |
| callDataClassFunction, callEntityFunction, callEntityCollectionFunction | Call ORDA functions by name and path (same REST rules as above). |
| callDatastorePath, createClient’s rpc | Arbitrary segments under the REST root, e.g. datastore or singleton paths. |
| buildListSearchParams, buildMethodSelectionQuery, … | Query-string helpers aligned with 4D. |
| QuadroHttpError | Thrown on non-OK HTTP with status and body text. |
The generated createClient is the recommended surface; these are for tooling, tests, or custom wrappers.
Errors
Failed HTTP responses throw QuadroHttpError (status + response body). Handle network errors separately (fetch failures). When events is configured, failures also emit a QuadroErrorEvent with message, error, and optional status before the error propagates.
Monorepo development
From packages/client:
bun run typecheck
bun run buildExample generate from the fixture catalog (adjust file:// to an absolute path on your machine):
bun run src/cli.ts generate --url file:///ABS/path/to/assets/catalog.json --out ../../.quadrokit/generated-demo