npm package: @quadrokit/client
Description: Typed 4D REST client and catalog code generator for QuadroKit
Latest version: 0.3.23

# `@quadrokit/client`

Typed [**4D REST**](https://developer.4d.com/docs/REST/REST_requests) client: a small runtime (`fetch` with `credentials: 'include'`, lists, entity sets, [`$attributes`](https://developer.4d.com/docs/REST/attributes)) and a **code generator** that turns a catalog JSON into a typed `createClient` and entity types.

---

## Install

```bash
npm add @quadrokit/client
# or
bun add @quadrokit/client
```

The 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 for `QuadroEventBus` (install **`rxjs`** in your app).

---

## Generate the client from your 4D catalog

Install adds the **`quadrokit-client`** binary (alias: **`quadrokit`**).

```bash
bunx quadrokit-client generate \
  --url 'http://localhost:7080/rest/$catalog/$all' \
  --out .quadrokit/generated
```

### Why `$catalog/$all`?

Use a URL that returns the **full** catalog (dataclasses **with attributes**). Plain [`/rest/$catalog`](https://developer.4d.com/docs/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](#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](#narrowing-function-signatures-override-files)). 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:

```ts
import { createClient } from './.quadrokit/generated/client.gen.js'
import type { Agency } from './.quadrokit/generated/types.gen.js'
```

---

## Creating the client

```ts
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](#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.).

```ts
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.
- **`events`** getter — the bus instance, if any.

---

## RxJS (`@quadrokit/client/rx`)

Install **`rxjs`** in your app, then bridge the bus to an observable:

```ts
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.

```ts
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`](https://developer.4d.com/docs/REST/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](https://developer.4d.com/docs/REST/classFunctions.html)):

| 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 type **`A`** (second generic on dataclass functions; default `readonly 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](https://developer.4d.com/docs/ORDA/ordaClasses#onhttpget-keyword).
- **`unwrapResult`**: when `true` (default), `{ "result": x }` responses are unwrapped to `x`.

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:

```ts
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`**: resolved **`Promise`** result 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>`** — same **`R`** / **`A`**; real calls also accept **`init`** with **`EntityCollectionMethodOptions`** (`selection`, `entitySet`, …).
- **`QuadroDatastoreFn<B, R>`** — **`B`**: body type; **`R`**: result type for **`quadro.<datastoreMethod>(body?)`**.

### Examples

**Per-class overrides** (`entities/Car.overrides.ts` in the split layout):

```ts
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`):

```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](#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`:

```bash
bun run typecheck
bun run build
```

Example generate from the fixture catalog (adjust `file://` to an absolute path on your machine):

```bash
bun run src/cli.ts generate --url file:///ABS/path/to/assets/catalog.json --out ../../.quadrokit/generated-demo
```

---

## Further reading

- [4D REST requests](https://developer.4d.com/docs/REST/REST_requests)
- [REST `$attributes`](https://developer.4d.com/docs/REST/attributes)
- [REST `$method` (entity set, release, …)](https://developer.4d.com/docs/REST/method)
- [Calling class functions (ORDA)](https://developer.4d.com/docs/REST/classFunctions.html)