@quadrokit/client 0.3.13 → 0.3.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,7 @@ The package exposes:
16
16
 
17
17
  - **`@quadrokit/client`** — runtime + types (and anything your generated code needs).
18
18
  - **`@quadrokit/client/runtime`** — low-level helpers if you build custom integrations.
19
+ - **`@quadrokit/client/rx`** — optional RxJS bridge for `QuadroEventBus` (install **`rxjs`** in your app).
19
20
 
20
21
  ---
21
22
 
@@ -44,6 +45,7 @@ Use a URL that returns the **full** catalog (dataclasses **with attributes**). P
44
45
  | **`--login-url`** | Full login URL (default: `{catalog origin}/api/login`). |
45
46
  | **`-v` / `--verbose`** | Step-by-step logs on stderr. |
46
47
  | **`--insecure-tls`** | Skip TLS verification (dev / self-signed HTTPS only). |
48
+ | **`--no-split-type-files`** | Emit a single **`types.gen.ts`** (legacy). **Default:** split typings — **`entities/<Class>.gen.ts`**, optional **`datastore.gen.ts`**, and **`types.gen.ts`** as the barrel. |
47
49
 
48
50
  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`.
49
51
 
@@ -51,9 +53,11 @@ Environment variables (often via `.env` in the project root): `VITE_4D_ORIGIN`,
51
53
 
52
54
  | File | Purpose |
53
55
  |------|---------|
54
- | **`types.gen.ts`** | Entity interfaces; **`*Path`** aliases use **`QuadroAttributePaths<T>`** (recursive dot paths, depth-limited for circular relations); **`QuadroClient`** typing. |
56
+ | **`types.gen.ts`** | Barrel: **`QuadroClient`** typing; re-exports entity types and **`*Path`** aliases. |
57
+ | **`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`). |
58
+ | **`datastore.gen.ts`** | When the catalog lists datastore REST methods (e.g. `testFn`): **`QuadroDatastoreMethodFn`**. Omitted when there are no such exposed methods. **`authentify`** is not special-cased here — it is typed under **`QuadroClient`** only when the catalog exposes it (see **`hasAuthentify`** in **`catalog.gen.json`**). |
55
59
  | **`catalog.gen.json`** | Catalog runtime spec (dataclass layouts, methods, relations) consumed by `@quadrokit/client/runtime` — keeps **`client.gen.ts`** tiny. |
56
- | **`client.gen.ts`** | Thin `createClient(config)` that wires `QuadroHttp` + `buildQuadroClientFromCatalogSpec` + `catalog.gen.json`. |
60
+ | **`client.gen.ts`** | Thin `createClient(config)` that wires `QuadroHttp` + `buildQuadroClientFromCatalogSpec` + `catalog.gen.json`. Config supports optional **`events`** (see [Request lifecycle events](#request-lifecycle-events)). |
57
61
  | **`meta.json`** | `__NAME`, `sessionCookieName` hint for 4D session cookies. |
58
62
 
59
63
  Point your app imports at the generated folder, for example:
@@ -80,10 +84,90 @@ const quadro = createClient({
80
84
  })
81
85
  ```
82
86
 
87
+ Optional **`events: new QuadroEventBus()`** (from **`@quadrokit/client/runtime`**) enables [request lifecycle events](#request-lifecycle-events) on every call through the client.
88
+
83
89
  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.
84
90
 
85
91
  ---
86
92
 
93
+ ## Request lifecycle events
94
+
95
+ 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.).
96
+
97
+ ```ts
98
+ import { createClient } from './.quadrokit/generated/client.gen.js'
99
+ import { QuadroEventBus } from '@quadrokit/client/runtime'
100
+
101
+ const events = new QuadroEventBus()
102
+
103
+ const quadro = createClient({
104
+ baseURL: '/rest',
105
+ events,
106
+ })
107
+
108
+ const unsub = events.subscribe((e) => {
109
+ if (e.type === 'loading') {
110
+ console.debug(e.context.operation, e.path, e.method)
111
+ }
112
+ if (e.type === 'success') {
113
+ console.debug(e.durationMs, e.body)
114
+ }
115
+ if (e.type === 'error') {
116
+ console.warn(e.status, e.message, e.error)
117
+ }
118
+ })
119
+
120
+ // later: unsub()
121
+ ```
122
+
123
+ You can also subscribe on the HTTP instance: **`quadro._http.subscribe(listener)`** returns an unsubscribe function.
124
+
125
+ ### Event shapes (`QuadroClientEvent`)
126
+
127
+ | Field | `loading` | `success` | `error` |
128
+ |-------|-----------|-----------|---------|
129
+ | **`type`** | `'loading'` | `'success'` | `'error'` |
130
+ | **`operationId`** | Correlates one start → one outcome | same | same |
131
+ | **`context`** | **`QuadroRequestContext`**: **`operation`** (**`QuadroOperation`** string), optional **`className`**, **`methodName`**, **`entityKey`**, **`attributes`** | same | same |
132
+ | **`path`** | Request path | same | same |
133
+ | **`method`** | HTTP method | — | — |
134
+ | **`startedAt`** | Timestamp | — | — |
135
+ | **`durationMs`** | — | Elapsed | Elapsed |
136
+ | **`status`** | — | If HTTP response is raw `Response` | If thrown value has HTTP **`status`** (e.g. **`QuadroHttpError`**) |
137
+ | **`body`** | — | Result: parsed JSON, rows, `void`, or a small summary for raw **`Response`** | — |
138
+ | **`message` / `error`** | — | — | User-facing message + original error |
139
+
140
+ ### Operations (`QuadroOperation`)
141
+
142
+ Examples include: **`http.json`**, **`http.void`**, **`http.request`**, **`collection.list`**, **`collection.release`**, **`dataclass.get`**, **`dataclass.delete`**, **`function.dataclass`**, **`function.entity`**, **`function.entityCollection`**, **`datastore`**, **`auth.login`**.
143
+
144
+ ### `QuadroHttp` helpers (advanced)
145
+
146
+ When **`events`** is set, **`json`**, **`void`**, and **`request`** accept an optional trailing **`QuadroRequestContext`** so emissions can be tagged. **`QuadroHttp`** also exposes:
147
+
148
+ - **`rawRequest(path, init)`** — fetch without emitting (used inside multi-step flows).
149
+ - **`runWithEvents(context, path, method, fn)`** — run an async function with the same loading/success/error envelope as the built-in methods.
150
+ - **`events`** getter — the bus instance, if any.
151
+
152
+ ---
153
+
154
+ ## RxJS (`@quadrokit/client/rx`)
155
+
156
+ Install **`rxjs`** in your app, then bridge the bus to an observable:
157
+
158
+ ```ts
159
+ import { quadroEventsObservable } from '@quadrokit/client/rx'
160
+ import { filter } from 'rxjs/operators'
161
+
162
+ const sub = quadroEventsObservable(events)
163
+ .pipe(filter((e) => e.type === 'error'))
164
+ .subscribe((e) => console.warn(e.message))
165
+ ```
166
+
167
+ **`rxjs`** is an **optional peer dependency** of `@quadrokit/client`; you only need it if you import `@quadrokit/client/rx`.
168
+
169
+ ---
170
+
87
171
  ## Dataclass API (generated)
88
172
 
89
173
  For each exposed dataclass, the client exposes a namespace such as `quadro.Agency`, `quadro.Reservation`, etc.
@@ -143,11 +227,11 @@ If your 4D project exposes methods in the REST catalog, the generator wires them
143
227
 
144
228
  | Catalog `applyTo` | Where it appears in JS | REST shape (simplified) |
145
229
  |-------------------|-------------------------|-------------------------|
146
- | **`dataClass`** | `quadro.Agency.agencyStats<R>(args, init?)` | `POST /rest/{Class}/{function}` with JSON array body |
230
+ | **`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 |
147
231
  | **`entityCollection`** | On **`all()` / `query()`** handles and on related `{ list, … }` APIs | `POST /rest/{Class}/{function}` + optional `selection`, `entitySet` |
148
232
  | **`entity`** | On each **mapped entity** row | `POST /rest/{Class}({key})/{function}` |
149
233
 
150
- - **`args`**: `readonly unknown[]` — 4D receives a **JSON array** of parameters (scalars, entities, entity selections per 4D rules).
234
+ - **`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).
151
235
  - **`init`**: optional `{ method: 'GET' \| 'POST', unwrapResult?, signal?, … }` — GET uses `?$params=…` for [HTTP GET functions](https://developer.4d.com/docs/ORDA/ordaClasses#onhttpget-keyword).
152
236
  - **`unwrapResult`**: when `true` (default), `{ "result": x }` responses are unwrapped to `x`.
153
237
 
@@ -159,7 +243,8 @@ Entity-selection methods accept **`EntityCollectionMethodOptions`**:
159
243
  Example:
160
244
 
161
245
  ```ts
162
- const stats = await quadro.Agency.agencyStats<MyStatsRow>([from, to])
246
+ const stats = await quadro.Agency.agencyStats<MyStatsRow, [string, string]>([from, to])
247
+ // or: agencyStats<MyStatsRow>([from, to]) when you only need to fix the result type
163
248
 
164
249
  const col = quadro.Reservation.all({ pageSize: 20 })
165
250
  // After the handle exists, e.g. from a hook or manual create:
@@ -178,7 +263,8 @@ Import from **`@quadrokit/client/runtime`** when you need primitives without cod
178
263
 
179
264
  | Export | Use |
180
265
  |--------|-----|
181
- | **`QuadroHttp`** | Thin wrapper: `json()`, `void()`, `request()` with base URL + default headers. |
266
+ | **`QuadroHttp`** | Thin wrapper: **`json()`**, **`void()`**, **`request()`** with base URL + default headers; optional **`events`**; **`rawRequest`**, **`runWithEvents`**, **`subscribe`**. |
267
+ | **`QuadroEventBus`**, **`QuadroClientEvent`**, **`QuadroLoadingEvent`**, **`QuadroSuccessEvent`**, **`QuadroErrorEvent`**, **`QuadroRequestContext`**, **`QuadroOperation`** | Lifecycle event types and bus (see [Request lifecycle events](#request-lifecycle-events)). |
182
268
  | **`createCollection`** | Build a `CollectionHandle` from a `CollectionContext` + options + row mapper. |
183
269
  | **`callDataClassFunction`**, **`callEntityFunction`**, **`callEntityCollectionFunction`** | Call ORDA functions by name and path (same REST rules as above). |
184
270
  | **`callDatastorePath`**, **`createClient`’s `rpc`** | Arbitrary segments under the REST root, e.g. datastore or singleton paths. |
@@ -191,7 +277,7 @@ The generated `createClient` is the recommended surface; these are for tooling,
191
277
 
192
278
  ## Errors
193
279
 
194
- Failed HTTP responses throw **`QuadroHttpError`** (status + response body). Handle network errors separately (`fetch` failures).
280
+ 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.
195
281
 
196
282
  ---
197
283
 
package/dist/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import { catalogFetchInit, defaultLoginUrlFromCatalogUrl, fetch4DAdminSessionCookie, vLog, wrapFetchError, } from './generate/catalog-session.mjs';
5
6
  import { warnIfCatalogLacksAttributes, writeGenerated } from './generate/codegen.mjs';
@@ -13,6 +14,7 @@ function parseArgs(argv) {
13
14
  let out = '.quadrokit/generated';
14
15
  let verbose = false;
15
16
  let insecureTls = false;
17
+ let splitTypeFiles = true;
16
18
  for (let i = 0; i < argv.length; i++) {
17
19
  const a = argv[i];
18
20
  if (a === '--url' && argv[i + 1]) {
@@ -36,11 +38,14 @@ function parseArgs(argv) {
36
38
  else if (a === '--insecure-tls') {
37
39
  insecureTls = true;
38
40
  }
41
+ else if (a === '--no-split-type-files') {
42
+ splitTypeFiles = false;
43
+ }
39
44
  else if (!a.startsWith('-') && !command) {
40
45
  command = a;
41
46
  }
42
47
  }
43
- return { command, url, token, accessKey, loginUrl, out, verbose, insecureTls };
48
+ return { command, url, token, accessKey, loginUrl, out, verbose, insecureTls, splitTypeFiles };
44
49
  }
45
50
  /** 4D sometimes returns `dataClass` (summary) vs `dataClasses` (detail); align to `dataClasses`. */
46
51
  function normalizeCatalogJson(raw) {
@@ -64,7 +69,15 @@ async function loadCatalog(url, auth, debug) {
64
69
  vLog(debug, '🦙', 'QuadroKit catalog generate', 'Loading catalog…');
65
70
  vLog(debug, '📍', 'Catalog URL', url);
66
71
  if (url.startsWith('file:')) {
67
- const p = fileURLToPath(url);
72
+ let p;
73
+ try {
74
+ p = fileURLToPath(new URL(url));
75
+ }
76
+ catch {
77
+ // e.g. `file://../../assets/catalog.json` (invalid file URL host on some platforms)
78
+ const rest = url.replace(/^file:/i, '').replace(/^\/+/, '');
79
+ p = path.resolve(process.cwd(), rest);
80
+ }
68
81
  vLog(debug, '📂', 'Local file', p);
69
82
  const text = await readFile(p, 'utf8');
70
83
  vLog(debug, '✅', 'Catalog JSON', `Read ${text.length} bytes from disk`);
@@ -128,6 +141,10 @@ Debug / TLS:
128
141
  -v, --verbose Friendly step-by-step logs (emojis) on stderr
129
142
  --insecure-tls Skip TLS certificate verification (dev/self-signed HTTPS only)
130
143
 
144
+ Output layout (default: split entity + datastore typings):
145
+ --no-split-type-files Emit a single types.gen.ts (legacy); default is split files:
146
+ entities/<Class>.gen.ts, optional datastore.gen.ts, types.gen.ts barrel.
147
+
131
148
  Env / .env: VITE_4D_ORIGIN, QUADROKIT_ACCESS_KEY, QUADROKIT_LOGIN_URL, QUADROKIT_CATALOG_TOKEN,
132
149
  QUADROKIT_GENERATE_VERBOSE, QUADROKIT_INSECURE_TLS (set to 1 / true / yes)
133
150
 
@@ -157,7 +174,7 @@ Examples:
157
174
  token: accessKey ? undefined : bearer,
158
175
  }, debug);
159
176
  warnIfCatalogLacksAttributes(catalog);
160
- await writeGenerated(out, catalog);
177
+ await writeGenerated(out, catalog, { splitTypeFiles: parsed.splitTypeFiles });
161
178
  if (debug.verbose) {
162
179
  vLog(debug, '🎉', 'All set', `Generated client → ${out}`);
163
180
  }
@@ -16,8 +16,19 @@ export declare function buildCatalogRuntimeSpec(catalog: CatalogJson): {
16
16
  }[];
17
17
  keyNamesByClass: Record<string, string[]>;
18
18
  hasAuthentify: boolean;
19
+ datastoreMethodNames: string[];
19
20
  };
20
21
  /** Warn when the catalog lists dataclasses but omits attributes (e.g. plain `GET /rest/$catalog` instead of `/rest/$catalog/$all`). */
21
22
  export declare function warnIfCatalogLacksAttributes(catalog: CatalogJson): void;
22
- export declare function writeGenerated(outDir: string, catalog: CatalogJson): Promise<void>;
23
+ export interface WriteGeneratedOptions {
24
+ /**
25
+ * When `true` (default), emit each exposed dataclass as `entities/<Name>.gen.ts`, optional
26
+ * `datastore.gen.ts` (per-method `Datastore_*` aliases + `QuadroDatastoreMethodFn`), and a barrel `types.gen.ts`.
27
+ * When `false`, emit a single `types.gen.ts` (legacy layout).
28
+ * `entities/<Name>.overrides.ts` and `datastore.overrides.ts` are created only if missing; existing
29
+ * entity override files are preserved when the `entities/` folder is regenerated.
30
+ */
31
+ splitTypeFiles?: boolean;
32
+ }
33
+ export declare function writeGenerated(outDir: string, catalog: CatalogJson, options?: WriteGeneratedOptions): Promise<void>;
23
34
  //# sourceMappingURL=codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../../src/generate/codegen.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAsC,WAAW,EAAE,MAAM,mBAAmB,CAAA;AA4GxF,yFAAyF;AACzF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,WAAW,GAAG;IAC7D,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAA;QACZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QACnC,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,iBAAiB,EAAE,MAAM,EAAE,CAAA;QAC3B,2BAA2B,EAAE,MAAM,EAAE,CAAA;QACrC,oBAAoB,EAAE,MAAM,EAAE,CAAA;QAC9B,SAAS,EAAE;YACT,IAAI,EAAE,MAAM,CAAA;YACZ,WAAW,EAAE,MAAM,CAAA;YACnB,2BAA2B,EAAE,MAAM,EAAE,CAAA;SACtC,EAAE,CAAA;KACJ,EAAE,CAAA;IACH,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACzC,aAAa,EAAE,OAAO,CAAA;CACvB,CAuBA;AA6GD,uIAAuI;AACvI,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAYvE;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBxF"}
1
+ {"version":3,"file":"codegen.d.ts","sourceRoot":"","sources":["../../src/generate/codegen.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAGV,WAAW,EAEZ,MAAM,mBAAmB,CAAA;AAmd1B,yFAAyF;AACzF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,WAAW,GAAG;IAC7D,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAA;QACZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QACnC,QAAQ,EAAE,MAAM,EAAE,CAAA;QAClB,iBAAiB,EAAE,MAAM,EAAE,CAAA;QAC3B,2BAA2B,EAAE,MAAM,EAAE,CAAA;QACrC,oBAAoB,EAAE,MAAM,EAAE,CAAA;QAC9B,SAAS,EAAE;YACT,IAAI,EAAE,MAAM,CAAA;YACZ,WAAW,EAAE,MAAM,CAAA;YACnB,2BAA2B,EAAE,MAAM,EAAE,CAAA;SACtC,EAAE,CAAA;KACJ,EAAE,CAAA;IACH,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;IACzC,aAAa,EAAE,OAAO,CAAA;IACtB,oBAAoB,EAAE,MAAM,EAAE,CAAA;CAC/B,CAwBA;AAoOD,uIAAuI;AACvI,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAYvE;AAED,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAqCD,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,WAAW,EACpB,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,IAAI,CAAC,CA2Ff"}
@@ -1,6 +1,11 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { sessionCookieName } from '@quadrokit/shared';
4
+ /**
5
+ * Suffix for import specifiers in generated `.ts` (`./Foo.gen.js`).
6
+ * Build with template + this const so `rename-dist-js-to-mjs` does not rewrite emitted paths in `dist/` to `.mjs`.
7
+ */
8
+ const EMITTED_TS_IMPORT_SUFFIX = '.js';
4
9
  function map4dType(attr) {
5
10
  const t = attr.type ?? 'unknown';
6
11
  switch (t) {
@@ -83,19 +88,315 @@ function emitInterface(dc) {
83
88
  lines.push('}');
84
89
  return lines.join('\n');
85
90
  }
91
+ /** Class names referenced as `relatedEntity` types (for per-entity file imports). Excludes self-references. */
92
+ function referencedEntityTypes(dc) {
93
+ const names = new Set();
94
+ for (const a of dc.attributes ?? []) {
95
+ if (a.kind === 'relatedEntity' && a.type && a.type !== dc.name) {
96
+ names.add(a.type);
97
+ }
98
+ }
99
+ return [...names].sort((x, y) => x.localeCompare(y));
100
+ }
101
+ function entityGenNamespaceImportName(className) {
102
+ return `Gen${className}`;
103
+ }
104
+ function emitQuadroFnTypesFile() {
105
+ return `/* eslint-disable */
106
+ /* Auto-generated by @quadrokit/client — do not edit */
107
+
108
+ import type { ClassFunctionHttpOptions, EntityCollectionMethodOptions } from '@quadrokit/client/runtime';
109
+
110
+ /** When the matching \`*Overrides\` interface defines a key, that signature wins; otherwise \`D\`. */
111
+ export type ResolveOverride<O extends object, K extends string, D> = K extends keyof O
112
+ ? O[Extract<K, keyof O>] extends infer V
113
+ ? [V] extends [never]
114
+ ? D
115
+ : V extends undefined
116
+ ? D
117
+ : V
118
+ : D
119
+ : D;
120
+
121
+ export type QuadroDataClassFn = <
122
+ R = unknown,
123
+ A extends readonly unknown[] = readonly unknown[],
124
+ >(
125
+ args?: A,
126
+ init?: ClassFunctionHttpOptions & { unwrapResult?: boolean },
127
+ ) => Promise<R>;
128
+
129
+ export type QuadroEntityFn = <
130
+ R = unknown,
131
+ A extends readonly unknown[] = readonly unknown[],
132
+ >(
133
+ args?: A,
134
+ init?: ClassFunctionHttpOptions & { unwrapResult?: boolean },
135
+ ) => Promise<R>;
136
+
137
+ export type QuadroEntityCollectionFn = <
138
+ R = unknown,
139
+ A extends readonly unknown[] = readonly unknown[],
140
+ >(
141
+ args?: A,
142
+ init?: EntityCollectionMethodOptions,
143
+ ) => Promise<R>;
144
+
145
+ export type QuadroDatastoreFn = <
146
+ B = unknown,
147
+ R = unknown,
148
+ >(
149
+ body?: B,
150
+ init?: { method?: 'GET' | 'POST' },
151
+ ) => Promise<R>;
152
+ `;
153
+ }
154
+ function emitOverridesStubFile(className, quadroFnRelativeToStub) {
155
+ return `/* eslint-disable */
156
+ /**
157
+ * Optional: narrow REST method typings for ${className}.
158
+ * Add entries under the interfaces (e.g. findACar: QuadroDataClassFn<[string, string], Car[]>).
159
+ * Import fn types from \`'${quadroFnRelativeToStub}'\` when you add entries (or reference re-exports below).
160
+ */
161
+ export type {
162
+ QuadroDataClassFn,
163
+ QuadroEntityFn,
164
+ QuadroEntityCollectionFn,
165
+ } from '${quadroFnRelativeToStub}';
166
+
167
+ export interface ${className}DataclassOverrides {}
168
+
169
+ export interface ${className}EntityOverrides {}
170
+
171
+ export interface ${className}EntityCollectionOverrides {}
172
+ `;
173
+ }
174
+ function emitDatastoreOverridesStub(quadroFnsRelativeToStub) {
175
+ return `/* eslint-disable */
176
+ /**
177
+ * Optional: narrow top-level datastore REST methods on the generated client (e.g. \`testFn\`).
178
+ * Add one property per catalog method name on \`DatastoreOverrides\`.
179
+ * Import \`QuadroDatastoreFn\` from \`'${quadroFnsRelativeToStub}'\` when you add entries.
180
+ */
181
+ export type { QuadroDatastoreFn } from '${quadroFnsRelativeToStub}';
182
+
183
+ export interface DatastoreOverrides {}
184
+ `;
185
+ }
186
+ /** Import names from \`_quadroFns.gen\` required for this class’s emitted method aliases (avoids unused imports under \`noUnusedLocals\`). */
187
+ function quadroFnTypeNamesForClass(dc) {
188
+ const dcMs = methodsForApply(dc, 'dataClass');
189
+ const emMs = methodsForApply(dc, 'entity');
190
+ const ecMs = methodsForApply(dc, 'entityCollection');
191
+ const out = [];
192
+ if (dcMs.length > 0 || emMs.length > 0 || ecMs.length > 0) {
193
+ out.push('ResolveOverride');
194
+ }
195
+ if (dcMs.length > 0) {
196
+ out.push('QuadroDataClassFn');
197
+ }
198
+ if (emMs.length > 0) {
199
+ out.push('QuadroEntityFn');
200
+ }
201
+ if (ecMs.length > 0) {
202
+ out.push('QuadroEntityCollectionFn');
203
+ }
204
+ return out;
205
+ }
206
+ function quadroFnTypeNamesForCatalog(classes, dsNames) {
207
+ const set = new Set();
208
+ for (const c of classes) {
209
+ for (const n of quadroFnTypeNamesForClass(c)) {
210
+ set.add(n);
211
+ }
212
+ }
213
+ if (dsNames.length > 0) {
214
+ set.add('ResolveOverride');
215
+ set.add('QuadroDatastoreFn');
216
+ }
217
+ const order = [
218
+ 'ResolveOverride',
219
+ 'QuadroDataClassFn',
220
+ 'QuadroEntityFn',
221
+ 'QuadroEntityCollectionFn',
222
+ 'QuadroDatastoreFn',
223
+ ];
224
+ return order.filter((n) => set.has(n));
225
+ }
226
+ function overrideTypeNamesForClass(c) {
227
+ const parts = [];
228
+ if (methodsForApply(c, 'dataClass').length > 0) {
229
+ parts.push(`${c.name}DataclassOverrides`);
230
+ }
231
+ if (methodsForApply(c, 'entity').length > 0) {
232
+ parts.push(`${c.name}EntityOverrides`);
233
+ }
234
+ if (methodsForApply(c, 'entityCollection').length > 0) {
235
+ parts.push(`${c.name}EntityCollectionOverrides`);
236
+ }
237
+ return parts;
238
+ }
239
+ function methodNameLiteralForKey(name) {
240
+ return JSON.stringify(name);
241
+ }
242
+ function buildMethodAliasLines(dc) {
243
+ const dcMs = methodsForApply(dc, 'dataClass');
244
+ const emMs = methodsForApply(dc, 'entity');
245
+ const ecMs = methodsForApply(dc, 'entityCollection');
246
+ const dataclassLines = dcMs.map((m) => {
247
+ const id = sanitizeMethodId(m.name);
248
+ const key = methodNameLiteralForKey(m.name);
249
+ return `export type ${dc.name}_dataclass_${id} = ResolveOverride<${dc.name}DataclassOverrides, ${key}, QuadroDataClassFn>`;
250
+ });
251
+ const entityLines = emMs.map((m) => {
252
+ const id = sanitizeMethodId(m.name);
253
+ const key = methodNameLiteralForKey(m.name);
254
+ return `export type ${dc.name}_entity_${id} = ResolveOverride<${dc.name}EntityOverrides, ${key}, QuadroEntityFn>`;
255
+ });
256
+ const ecLines = ecMs.map((m) => {
257
+ const id = sanitizeMethodId(m.name);
258
+ const key = methodNameLiteralForKey(m.name);
259
+ return `export type ${dc.name}_entityCollection_${id} = ResolveOverride<${dc.name}EntityCollectionOverrides, ${key}, QuadroEntityCollectionFn>`;
260
+ });
261
+ const entityMap = emMs.length > 0
262
+ ? `export type ${dc.name}EntityMethodMap = {
263
+ ${emMs
264
+ .map((m) => ` ${objectPropertyKey(m.name)}: ${dc.name}_entity_${sanitizeMethodId(m.name)};`)
265
+ .join('\n')}
266
+ };
267
+
268
+ export type ${dc.name}WithEntityMethods = ${dc.name} & Partial<${dc.name}EntityMethodMap>;
269
+ `
270
+ : '';
271
+ const ecMap = ecMs.length > 0
272
+ ? `export type ${dc.name}EntityCollectionMethodMap = {
273
+ ${ecMs
274
+ .map((m) => ` ${objectPropertyKey(m.name)}: ${dc.name}_entityCollection_${sanitizeMethodId(m.name)};`)
275
+ .join('\n')}
276
+ };
277
+ `
278
+ : '';
279
+ const methodBlock = [...dataclassLines, ...entityLines, ...ecLines].join('\n');
280
+ const tail = [entityMap, ecMap].filter(Boolean).join('\n');
281
+ return [methodBlock, tail].filter(Boolean).join('\n\n');
282
+ }
283
+ function emitEntityTypeBody(dc, opts) {
284
+ const refs = referencedEntityTypes(dc);
285
+ const fnNames = quadroFnTypeNamesForClass(dc);
286
+ const fnImport = fnNames.length > 0
287
+ ? `import type {\n ${fnNames.join(',\n ')},\n} from '${opts.quadroFnImport}';\n`
288
+ : '';
289
+ const ovNames = overrideTypeNamesForClass(dc);
290
+ const ovImport = ovNames.length > 0
291
+ ? `import type {\n ${ovNames.join(',\n ')},\n} from '${opts.overridesImport}';\n`
292
+ : '';
293
+ const runtimeImport = `import type { QuadroAttributePaths } from '@quadrokit/client/runtime';
294
+ `;
295
+ const refImports = refs.length > 0
296
+ ? `${refs
297
+ .map((r) => `import type { ${r} } from './${r}.gen${EMITTED_TS_IMPORT_SUFFIX}';`)
298
+ .join('\n')}\n`
299
+ : '';
300
+ const iface = emitInterface(dc);
301
+ const pathType = `export type ${dc.name}Path = QuadroAttributePaths<${dc.name}>;
302
+ `;
303
+ const methodAliases = buildMethodAliasLines(dc);
304
+ const blocks = [fnImport, ovImport, runtimeImport, refImports, iface, '', pathType, methodAliases]
305
+ .filter((x) => x !== '')
306
+ .join('\n');
307
+ return blocks;
308
+ }
309
+ function emitEntityTypeFile(dc) {
310
+ const header = `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n`;
311
+ const quadroPath = `../_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}`;
312
+ const body = emitEntityTypeBody(dc, {
313
+ quadroFnImport: quadroPath,
314
+ overridesImport: `./${dc.name}.overrides${EMITTED_TS_IMPORT_SUFFIX}`,
315
+ });
316
+ return `${header}${body}\n`;
317
+ }
318
+ function buildDatastoreMethodAliasLines(catalog) {
319
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
320
+ if (dsNames.length === 0) {
321
+ return '';
322
+ }
323
+ return dsNames
324
+ .map((n) => {
325
+ const id = sanitizeMethodId(n);
326
+ const key = methodNameLiteralForKey(n);
327
+ return `export type Datastore_${id} = ResolveOverride<DatastoreOverrides, ${key}, QuadroDatastoreFn>`;
328
+ })
329
+ .join('\n');
330
+ }
331
+ function emitDatastoreTypesFile(catalog) {
332
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
333
+ const header = `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n\n`;
334
+ const imports = `import type {
335
+ ResolveOverride,
336
+ QuadroDatastoreFn,
337
+ } from './_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}';
338
+
339
+ import type { DatastoreOverrides } from './datastore.overrides${EMITTED_TS_IMPORT_SUFFIX}';
340
+
341
+ `;
342
+ const base = `/** Default datastore method shape; per-method aliases below respect \`DatastoreOverrides\`. */
343
+ export type QuadroDatastoreMethodFn = QuadroDatastoreFn;
344
+
345
+ `;
346
+ const aliases = dsNames.length > 0 ? `${buildDatastoreMethodAliasLines(catalog)}\n` : '';
347
+ return `${header}${imports}${base}${aliases}`;
348
+ }
86
349
  /** Include dataclasses unless explicitly not exposed (`exposed === false`). Omitted `exposed` is treated as true. */
87
350
  function exposedDataClasses(catalog) {
88
351
  return catalog.dataClasses?.filter((d) => d.exposed !== false) ?? [];
89
352
  }
353
+ /** Normalized `applyTo` for catalog method rows (4D: `dataClass`, `entity`, `entityCollection`). */
354
+ const APPLY_TO_KIND = {
355
+ dataClass: 'dataclass',
356
+ entity: 'entity',
357
+ entityCollection: 'entitycollection',
358
+ };
359
+ function normalizeCatalogMethodApplyTo(applyTo) {
360
+ return applyTo?.trim().toLowerCase().replace(/\s+/g, '') ?? '';
361
+ }
90
362
  /** ORDA catalog methods by `applyTo` (4D: dataClass, entityCollection, entity). */
91
- function methodsForApply(dc, applyTo) {
363
+ function methodsForApply(dc, kind) {
92
364
  if (!dc) {
93
365
  return [];
94
366
  }
95
- return (dc.methods ?? []).filter((m) => m.applyTo === applyTo && m.exposed !== false);
367
+ const want = APPLY_TO_KIND[kind];
368
+ return (dc.methods ?? []).filter((m) => normalizeCatalogMethodApplyTo(m.applyTo) === want && m.exposed !== false);
369
+ }
370
+ function sanitizeMethodId(name) {
371
+ return name.replace(/[^\w$]/g, '_').replace(/_+/g, '_');
372
+ }
373
+ /** Top-level ORDA `catalog.methods` entry targeting the datastore (4D: `dataStore`; compare case-insensitively). */
374
+ function catalogMethodApplyToIsDatastore(applyTo) {
375
+ return applyTo?.trim().toLowerCase() === 'datastore';
96
376
  }
377
+ /**
378
+ * True when the catalog lists an exposed `authentify` datastore method (same rules as other datastore methods).
379
+ * Not emitted in types/runtime when absent or `exposed: false`.
380
+ */
97
381
  function catalogHasAuthentify(catalog) {
98
- return Boolean(catalog.methods?.some((m) => m.name === 'authentify' && m.applyTo === 'dataStore'));
382
+ return Boolean(catalog.methods?.some((m) => m.name?.toLowerCase() === 'authentify' &&
383
+ catalogMethodApplyToIsDatastore(m.applyTo) &&
384
+ m.exposed !== false));
385
+ }
386
+ /** Top-level ORDA `methods` with datastore `applyTo`, excluding `authentify` (typed as `authentify.login` when present). */
387
+ function datastoreMethodsExcludingAuthentify(catalog) {
388
+ return (catalog.methods ?? [])
389
+ .filter((m) => Boolean(m.name) &&
390
+ catalogMethodApplyToIsDatastore(m.applyTo) &&
391
+ m.exposed !== false &&
392
+ m.name.toLowerCase() !== 'authentify')
393
+ .map((m) => m.name);
394
+ }
395
+ function isValidJsIdentifier(name) {
396
+ return /^[A-Za-z_$][\w$]*$/.test(name);
397
+ }
398
+ function objectPropertyKey(name) {
399
+ return isValidJsIdentifier(name) ? name : JSON.stringify(name);
99
400
  }
100
401
  /** Serializable spec consumed by {@link buildQuadroClientFromCatalogSpec} at runtime. */
101
402
  export function buildCatalogRuntimeSpec(catalog) {
@@ -103,6 +404,7 @@ export function buildCatalogRuntimeSpec(catalog) {
103
404
  const byName = new Map(classes.map((c) => [c.name, c]));
104
405
  return {
105
406
  hasAuthentify: catalogHasAuthentify(catalog),
407
+ datastoreMethodNames: datastoreMethodsExcludingAuthentify(catalog),
106
408
  keyNamesByClass: Object.fromEntries(classes.map((x) => [x.name, [...keyNames(x)]])),
107
409
  classes: classes.map((c) => ({
108
410
  name: c.name,
@@ -119,62 +421,148 @@ export function buildCatalogRuntimeSpec(catalog) {
119
421
  })),
120
422
  };
121
423
  }
122
- function emitQuadroClientTypeBlock(catalog, withEntities) {
424
+ function emitQuadroClientTypeBlock(catalog, withEntities, datastoreFnStyle = 'inline',
425
+ /** When true, method types are referenced as `GenCar.Car_dataclass_*` from per-entity files. */
426
+ splitLayout = false) {
123
427
  const classes = exposedDataClasses(catalog);
124
428
  const hasAuthentify = catalogHasAuthentify(catalog);
429
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
125
430
  const authLine = hasAuthentify
126
431
  ? ` authentify: { login: (body: { email: string; password: string }) => Promise<unknown> };\n`
127
432
  : '';
433
+ const dsProps = dsNames.length > 0
434
+ ? `${dsNames
435
+ .map((n) => {
436
+ const id = sanitizeMethodId(n);
437
+ const ref = datastoreFnStyle === 'imported' ? `GenDatastore.Datastore_${id}` : `Datastore_${id}`;
438
+ return ` ${objectPropertyKey(n)}: ${ref};`;
439
+ })
440
+ .join('\n')}\n`
441
+ : '';
128
442
  const tail = ` rpc: (segments: string[], init?: { method?: 'GET' | 'POST'; body?: unknown }) => Promise<unknown>;
129
443
  sessionCookieName: string;
130
444
  _http: QuadroHttp;
131
445
  };`;
132
446
  if (!withEntities || classes.length === 0) {
133
447
  return `export type QuadroClient = {
134
- ${authLine}${tail}
448
+ ${authLine}${dsProps}${tail}
135
449
  `;
136
450
  }
137
- const dcFn = `type QuadroDataClassFn = <R = unknown>(args?: readonly unknown[], init?: ClassFunctionHttpOptions & { unwrapResult?: boolean }) => Promise<R>;
138
-
139
- `;
140
451
  const branches = classes
141
452
  .map((c) => {
142
- const keys = methodsForApply(c, 'dataClass')
143
- .map((m) => `'${m.name}'`)
144
- .join(' | ');
145
- const rhs = keys
146
- ? `BaseDataClassApi<${c.name}, ${c.name}Path> & { [K in ${keys}]: QuadroDataClassFn }`
147
- : `BaseDataClassApi<${c.name}, ${c.name}Path>`;
148
- return ` ${c.name}: ${rhs};`;
453
+ const dcMs = methodsForApply(c, 'dataClass');
454
+ if (dcMs.length === 0) {
455
+ return ` ${c.name}: BaseDataClassApi<${c.name}, ${c.name}Path>;`;
456
+ }
457
+ const ns = splitLayout ? entityGenNamespaceImportName(c.name) : null;
458
+ const props = dcMs
459
+ .map((m) => {
460
+ const id = sanitizeMethodId(m.name);
461
+ const ref = ns ? `${ns}.${c.name}_dataclass_${id}` : `${c.name}_dataclass_${id}`;
462
+ return ` ${objectPropertyKey(m.name)}: ${ref};`;
463
+ })
464
+ .join('\n');
465
+ return ` ${c.name}: BaseDataClassApi<${c.name}, ${c.name}Path> & {
466
+ ${props}
467
+ };`;
149
468
  })
150
469
  .join('\n');
151
- return `${dcFn}export type QuadroClient = {
152
- ${authLine}${branches}
470
+ return `export type QuadroClient = {
471
+ ${authLine}${dsProps}${branches}
153
472
  ${tail}
154
473
  `;
155
474
  }
156
- function emitTypes(catalog) {
475
+ function emitTypesMonolithic(catalog) {
157
476
  const classes = exposedDataClasses(catalog);
477
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
478
+ const quadroFnNames = quadroFnTypeNamesForCatalog(classes, dsNames);
479
+ const needsQuadroFns = quadroFnNames.length > 0;
158
480
  const interfaces = classes.map((c) => emitInterface(c));
159
481
  const pathTypes = classes.map((c) => {
160
482
  const name = `${c.name}Path`;
161
483
  return `export type ${name} = QuadroAttributePaths<${c.name}>;`;
162
484
  });
485
+ const methodAliasBlocks = classes.map((c) => buildMethodAliasLines(c)).join('\n\n');
486
+ const datastoreAliasBlock = dsNames.length > 0 ? `${buildDatastoreMethodAliasLines(catalog)}\n\n` : '';
163
487
  const header = `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n`;
164
488
  const pathNote = classes.length > 0
165
- ? `/**\n * *Path types use {@link QuadroAttributePaths} (recursive relation paths, depth-limited for circular graphs).\n * Override depth: \`type DeepEmployeePath = QuadroAttributePaths<Employee, 12>\`.\n */\n`
489
+ ? `/**\n * *Path types use {@link QuadroAttributePaths} (recursive relation paths, depth-limited for circular graphs).\n * Override depth: \`type DeepEmployeePath = QuadroAttributePaths<Employee, 12>\`.\n * Per-class REST method typings: \`*_dataclass_*\`, \`*_entity_*\`, \`*_entityCollection_*\`; narrow in \`./*.overrides.ts\`.\n * Datastore methods: \`Datastore_*\` aliases; narrow in \`./datastore.overrides.ts\`.\n */\n`
490
+ : dsNames.length > 0
491
+ ? `/**\n * Datastore REST methods: \`Datastore_*\` type aliases; narrow signatures in \`./datastore.overrides.ts\`.\n */\n`
492
+ : '';
493
+ const fnImport = needsQuadroFns
494
+ ? `import type {\n ${quadroFnNames.join(',\n ')},\n} from './_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}';\n\n`
166
495
  : '';
496
+ const datastoreOverrideImport = dsNames.length > 0
497
+ ? `import type { DatastoreOverrides } from './datastore.overrides${EMITTED_TS_IMPORT_SUFFIX}';
498
+
499
+ `
500
+ : '';
501
+ const overrideImportLines = classes
502
+ .map((c) => {
503
+ const parts = overrideTypeNamesForClass(c);
504
+ if (parts.length === 0) {
505
+ return null;
506
+ }
507
+ return `import type { ${parts.join(', ')} } from './${c.name}.overrides${EMITTED_TS_IMPORT_SUFFIX}';`;
508
+ })
509
+ .filter(Boolean);
510
+ const overrideImports = overrideImportLines.length > 0 ? `${overrideImportLines.join('\n')}\n\n` : '';
167
511
  const runtimeImport = classes.length > 0
168
- ? `import type { BaseDataClassApi, ClassFunctionHttpOptions, QuadroAttributePaths, QuadroHttp } from '@quadrokit/client/runtime';\n\n`
512
+ ? `import type { BaseDataClassApi, QuadroAttributePaths, QuadroHttp } from '@quadrokit/client/runtime';\n\n`
169
513
  : `import type { QuadroHttp } from '@quadrokit/client/runtime';\n\n`;
514
+ if (classes.length === 0) {
515
+ const raw = catalog.dataClasses?.length ?? 0;
516
+ const note = raw > 0
517
+ ? `\n/**\n * No entity types: all ${raw} data class(es) in the catalog have exposed: false.\n * Enable REST exposure for tables in 4D, then run quadrokit-client generate again.\n */\n`
518
+ : dsNames.length > 0
519
+ ? ''
520
+ : `\n/**\n * No entity types: the catalog has no dataClasses (or an empty list).\n */\n`;
521
+ return `${header}${runtimeImport}${fnImport}${datastoreOverrideImport}${note}${pathNote}${datastoreAliasBlock}${emitQuadroClientTypeBlock(catalog, false, 'inline', false)}`;
522
+ }
523
+ return `${header}${runtimeImport}${fnImport}${overrideImports}${datastoreOverrideImport}${interfaces.join('\n\n')}\n\n${pathNote}${pathTypes.join('\n\n')}\n\n${datastoreAliasBlock}${methodAliasBlocks}\n\n${emitQuadroClientTypeBlock(catalog, true, 'inline', false)}`;
524
+ }
525
+ function emitTypesSplitMain(catalog) {
526
+ const classes = exposedDataClasses(catalog);
527
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
528
+ const header = `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n`;
529
+ const pathNote = classes.length > 0
530
+ ? `/**\n * *Path types use {@link QuadroAttributePaths} (recursive relation paths, depth-limited for circular graphs).\n * Override depth: \`type DeepEmployeePath = QuadroAttributePaths<Employee, 12>\`.\n * Entity interfaces and REST method aliases live under \`./entities/*.gen.ts\`; optional signature overrides in \`./entities/*.overrides.ts\`.\n * Datastore: \`./datastore.gen.ts\` and \`./datastore.overrides.ts\` when present.\n */\n`
531
+ : dsNames.length > 0
532
+ ? `/**\n * Datastore method typings: \`./datastore.gen.ts\`; narrow signatures in \`./datastore.overrides.ts\`.\n */\n`
533
+ : '';
534
+ const datastoreImport = dsNames.length > 0
535
+ ? `import type * as GenDatastore from './datastore.gen${EMITTED_TS_IMPORT_SUFFIX}';\n\n`
536
+ : '';
537
+ const runtimeImport = classes.length > 0
538
+ ? `import type { BaseDataClassApi, QuadroHttp } from '@quadrokit/client/runtime';\n\n`
539
+ : `import type { QuadroHttp } from '@quadrokit/client/runtime';\n\n`;
540
+ const entityImports = classes.length > 0
541
+ ? `${classes
542
+ .map((c) => `import type { ${c.name}, ${c.name}Path } from './entities/${c.name}.gen${EMITTED_TS_IMPORT_SUFFIX}';`)
543
+ .join('\n')}\n\n`
544
+ : '';
545
+ const genNamespaceImports = classes.length > 0
546
+ ? `${classes
547
+ .filter((c) => methodsForApply(c, 'dataClass').length > 0)
548
+ .map((c) => `import type * as ${entityGenNamespaceImportName(c.name)} from './entities/${c.name}.gen${EMITTED_TS_IMPORT_SUFFIX}';`)
549
+ .join('\n')}${classes.some((c) => methodsForApply(c, 'dataClass').length > 0) ? '\n\n' : ''}`
550
+ : '';
551
+ const entityStarExports = classes.length > 0
552
+ ? `${classes
553
+ .map((c) => `export * from './entities/${c.name}.gen${EMITTED_TS_IMPORT_SUFFIX}';`)
554
+ .join('\n')}\n\n`
555
+ : '';
556
+ const datastoreStarExport = dsNames.length > 0 ? `export * from './datastore.gen${EMITTED_TS_IMPORT_SUFFIX}';\n\n` : '';
557
+ const datastoreStyle = dsNames.length > 0 ? 'imported' : 'inline';
170
558
  if (classes.length === 0) {
171
559
  const raw = catalog.dataClasses?.length ?? 0;
172
560
  const note = raw > 0
173
561
  ? `\n/**\n * No entity types: all ${raw} data class(es) in the catalog have exposed: false.\n * Enable REST exposure for tables in 4D, then run quadrokit-client generate again.\n */\n`
174
562
  : `\n/**\n * No entity types: the catalog has no dataClasses (or an empty list).\n */\n`;
175
- return `${header}${runtimeImport}${note}\n${emitQuadroClientTypeBlock(catalog, false)}`;
563
+ return `${header}${runtimeImport}${datastoreImport}${note}${pathNote}\n${datastoreStarExport}${emitQuadroClientTypeBlock(catalog, false, datastoreStyle, false)}`;
176
564
  }
177
- return `${header}${runtimeImport}${interfaces.join('\n\n')}\n\n${pathNote}${pathTypes.join('\n\n')}\n\n${emitQuadroClientTypeBlock(catalog, true)}`;
565
+ return `${header}${runtimeImport}${datastoreImport}${entityImports}${genNamespaceImports}${pathNote}${entityStarExports}${datastoreStarExport}\n${emitQuadroClientTypeBlock(catalog, true, datastoreStyle, true)}`;
178
566
  }
179
567
  function emitClient(catalog) {
180
568
  const dbName = catalog.__NAME ?? 'default';
@@ -226,10 +614,104 @@ export function warnIfCatalogLacksAttributes(catalog) {
226
614
  }
227
615
  console.error('quadrokit: catalog has dataclass names but no attribute details. Fetch the full catalog: GET /rest/$catalog/$all (plain /rest/$catalog omits attributes). See 4D REST docs: $catalog/$all.');
228
616
  }
229
- export async function writeGenerated(outDir, catalog) {
617
+ /** Writes `contents` only when `filePath` does not exist (used for `*.overrides.ts` stubs). */
618
+ async function writeStubIfMissing(filePath, contents) {
619
+ try {
620
+ await stat(filePath);
621
+ }
622
+ catch {
623
+ await writeFile(filePath, contents, 'utf8');
624
+ }
625
+ }
626
+ /** Reads existing `entities/*.overrides.ts` before the entities dir is recreated so user edits are kept. */
627
+ async function readExistingEntityOverrides(entitiesDir) {
628
+ const map = new Map();
629
+ try {
630
+ const st = await stat(entitiesDir);
631
+ if (!st.isDirectory()) {
632
+ return map;
633
+ }
634
+ const names = await readdir(entitiesDir);
635
+ for (const name of names) {
636
+ if (!name.endsWith('.overrides.ts')) {
637
+ continue;
638
+ }
639
+ const p = path.join(entitiesDir, name);
640
+ const s = await stat(p);
641
+ if (!s.isFile()) {
642
+ continue;
643
+ }
644
+ map.set(name, await readFile(p, 'utf8'));
645
+ }
646
+ }
647
+ catch {
648
+ // entities dir missing or unreadable
649
+ }
650
+ return map;
651
+ }
652
+ export async function writeGenerated(outDir, catalog, options = {}) {
653
+ const splitTypeFiles = options.splitTypeFiles !== false;
230
654
  await mkdir(outDir, { recursive: true });
655
+ const entitiesDir = path.join(outDir, 'entities');
656
+ const datastorePath = path.join(outDir, 'datastore.gen.ts');
657
+ const quadroFnsPath = path.join(outDir, '_quadroFns.gen.ts');
658
+ const classes = exposedDataClasses(catalog);
659
+ const dsNames = datastoreMethodsExcludingAuthentify(catalog);
660
+ if (splitTypeFiles) {
661
+ const preservedEntityOverrides = await readExistingEntityOverrides(entitiesDir);
662
+ await rm(entitiesDir, { recursive: true, force: true });
663
+ const needsQuadroFns = classes.length > 0 || dsNames.length > 0;
664
+ if (needsQuadroFns) {
665
+ await writeFile(quadroFnsPath, emitQuadroFnTypesFile(), 'utf8');
666
+ }
667
+ else {
668
+ await rm(quadroFnsPath, { force: true }).catch(() => { });
669
+ }
670
+ if (classes.length > 0) {
671
+ await mkdir(entitiesDir, { recursive: true });
672
+ for (const c of classes) {
673
+ await writeFile(path.join(entitiesDir, `${c.name}.gen.ts`), emitEntityTypeFile(c), 'utf8');
674
+ const overrideName = `${c.name}.overrides.ts`;
675
+ const overridePath = path.join(entitiesDir, overrideName);
676
+ const previous = preservedEntityOverrides.get(overrideName);
677
+ if (previous !== undefined) {
678
+ await writeFile(overridePath, previous, 'utf8');
679
+ }
680
+ else {
681
+ await writeStubIfMissing(overridePath, emitOverridesStubFile(c.name, `../_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}`));
682
+ }
683
+ }
684
+ }
685
+ if (dsNames.length > 0) {
686
+ await writeFile(datastorePath, emitDatastoreTypesFile(catalog), 'utf8');
687
+ await writeStubIfMissing(path.join(outDir, 'datastore.overrides.ts'), emitDatastoreOverridesStub(`./_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}`));
688
+ }
689
+ else {
690
+ await rm(datastorePath, { force: true }).catch(() => { });
691
+ }
692
+ await writeFile(path.join(outDir, 'types.gen.ts'), emitTypesSplitMain(catalog), 'utf8');
693
+ }
694
+ else {
695
+ await rm(entitiesDir, { recursive: true, force: true });
696
+ await rm(datastorePath, { force: true }).catch(() => { });
697
+ const needsQuadroFns = classes.length > 0 || dsNames.length > 0;
698
+ if (needsQuadroFns) {
699
+ await writeFile(quadroFnsPath, emitQuadroFnTypesFile(), 'utf8');
700
+ }
701
+ else {
702
+ await rm(quadroFnsPath, { force: true }).catch(() => { });
703
+ }
704
+ if (classes.length > 0) {
705
+ for (const c of classes) {
706
+ await writeStubIfMissing(path.join(outDir, `${c.name}.overrides.ts`), emitOverridesStubFile(c.name, `./_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}`));
707
+ }
708
+ }
709
+ if (dsNames.length > 0) {
710
+ await writeStubIfMissing(path.join(outDir, 'datastore.overrides.ts'), emitDatastoreOverridesStub(`./_quadroFns.gen${EMITTED_TS_IMPORT_SUFFIX}`));
711
+ }
712
+ await writeFile(path.join(outDir, 'types.gen.ts'), emitTypesMonolithic(catalog), 'utf8');
713
+ }
231
714
  await writeFile(path.join(outDir, 'catalog.gen.json'), `${JSON.stringify(buildCatalogRuntimeSpec(catalog), null, 2)}\n`, 'utf8');
232
- await writeFile(path.join(outDir, 'types.gen.ts'), emitTypes(catalog), 'utf8');
233
715
  await writeFile(path.join(outDir, 'client.gen.ts'), emitClient(catalog), 'utf8');
234
716
  await writeFile(path.join(outDir, 'meta.json'), JSON.stringify({
235
717
  __NAME: catalog.__NAME,
@@ -13,7 +13,10 @@ export interface CatalogClassRuntimeSpec {
13
13
  export interface CatalogRuntimeSpec {
14
14
  classes: readonly CatalogClassRuntimeSpec[];
15
15
  keyNamesByClass: Readonly<Record<string, readonly string[]>>;
16
- hasAuthentify: boolean;
16
+ /** Set from the catalog at generate time; omit or `false` when `authentify` is not an exposed datastore method. */
17
+ hasAuthentify?: boolean;
18
+ /** Top-level datastore REST methods from `catalog.methods` (`applyTo: dataStore`), excluding `authentify`. */
19
+ datastoreMethodNames: readonly string[];
17
20
  }
18
21
  /**
19
22
  * Builds the object returned by generated `createClient()` from `catalog.gen.json`.
@@ -1 +1 @@
1
- {"version":3,"file":"catalog-builder.d.ts","sourceRoot":"","sources":["../../src/runtime/catalog-builder.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,qBAAqB,EAC3B,MAAM,iBAAiB,CAAA;AAExB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAE3C,kEAAkE;AAClE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,2BAA2B,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9C,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAA;IACvC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,SAAS,uBAAuB,EAAE,CAAA;IAC3C,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAC5D,aAAa,EAAE,OAAO,CAAA;CACvB;AAqHD;;;GAGG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,kBAAkB,EACxB,IAAI,EAAE;IAAE,iBAAiB,EAAE,MAAM,CAAA;CAAE,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAoBzB"}
1
+ {"version":3,"file":"catalog-builder.d.ts","sourceRoot":"","sources":["../../src/runtime/catalog-builder.ts"],"names":[],"mappings":"AAEA,OAAO,EAKL,KAAK,qBAAqB,EAC3B,MAAM,iBAAiB,CAAA;AAExB,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAE3C,kEAAkE;AAClE,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC,2BAA2B,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9C,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAA;IACvC,SAAS,EAAE,SAAS,qBAAqB,EAAE,CAAA;CAC5C;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,SAAS,uBAAuB,EAAE,CAAA;IAC3C,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAA;IAC5D,mHAAmH;IACnH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,8GAA8G;IAC9G,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAA;CACxC;AAqHD;;;GAGG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,UAAU,EAChB,IAAI,EAAE,kBAAkB,EACxB,IAAI,EAAE;IAAE,iBAAiB,EAAE,MAAM,CAAA;CAAE,GAClC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA8BzB"}
@@ -84,11 +84,14 @@ function makeDataClassNamespace(http, c, keyNamesRecord) {
84
84
  export function buildQuadroClientFromCatalogSpec(http, spec, meta) {
85
85
  const keyNamesRecord = spec.keyNamesByClass;
86
86
  const out = {};
87
- if (spec.hasAuthentify) {
87
+ if (spec.hasAuthentify === true) {
88
88
  out.authentify = {
89
89
  login: (body) => callDatastorePath(http, ['authentify', 'login'], { body }, { operation: 'auth.login' }),
90
90
  };
91
91
  }
92
+ for (const name of spec.datastoreMethodNames ?? []) {
93
+ out[name] = (body, init) => callDatastorePath(http, [name], { body, method: init?.method }, { operation: 'datastore', methodName: name });
94
+ }
92
95
  for (const c of spec.classes) {
93
96
  out[c.name] = makeDataClassNamespace(http, c, keyNamesRecord);
94
97
  }
@@ -1 +1 @@
1
- {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../../src/runtime/collection.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0C,KAAK,UAAU,EAAE,MAAM,WAAW,CAAA;AACnF,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,YAAY,CAAA;AAGnB,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,gEAAgE;IAChE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,6FAA6F;IAC7F,gBAAgB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,IAAI,CAAA;CACnD;AAED,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,KAAK,CAAC,CAAA;AAsE3C;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,GAAG,EAAE,iBAAiB,EACtB,cAAc,EAAE,iBAAiB,EACjC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,gBAAgB,CAAC,CAAC,CAAC,CA+LrB;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,GAAG;IACnD,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,CAAA"}
1
+ {"version":3,"file":"collection.d.ts","sourceRoot":"","sources":["../../src/runtime/collection.ts"],"names":[],"mappings":"AACA,OAAO,EAA0C,KAAK,UAAU,EAAE,MAAM,WAAW,CAAA;AACnF,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,YAAY,CAAA;AAGnB,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,UAAU,CAAA;IAChB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,gEAAgE;IAChE,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC1B,uEAAuE;IACvE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sFAAsF;IACtF,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,6FAA6F;IAC7F,gBAAgB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,IAAI,CAAA;CACnD;AAED,MAAM,MAAM,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,OAAO,KAAK,CAAC,CAAA;AAsE3C;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAChC,GAAG,EAAE,iBAAiB,EACtB,cAAc,EAAE,iBAAiB,EACjC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC,GAChB,gBAAgB,CAAC,CAAC,CAAC,CA6LrB;AAED,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC,GAAG;IACnD,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACvB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { QuadroHttpError } from './errors.mjs';
1
2
  import { mountPathFromBaseURL, normalizeBaseURL } from './http.mjs';
2
3
  import { buildEntitySetCreationParams, buildEntitySetPageParams, buildListSearchParams, } from './query.mjs';
3
4
  import { unwrapEntityList } from './unwrap.mjs';
@@ -111,7 +112,7 @@ export function createCollection(ctx, initialOptions, mapRow) {
111
112
  });
112
113
  const textCreate = await resCreate.text();
113
114
  if (!resCreate.ok) {
114
- throw new Error(`Entity set creation failed ${resCreate.status}: ${textCreate.slice(0, 200)}`);
115
+ throw new QuadroHttpError(resCreate.status, textCreate);
115
116
  }
116
117
  const jsonCreate = textCreate ? JSON.parse(textCreate) : [];
117
118
  await parseEntitySetFromResponse(jsonCreate, resCreate);
@@ -130,7 +131,7 @@ export function createCollection(ctx, initialOptions, mapRow) {
130
131
  });
131
132
  const text = await res.text();
132
133
  if (!res.ok) {
133
- throw new Error(`List failed ${res.status}: ${text.slice(0, 200)}`);
134
+ throw new QuadroHttpError(res.status, text);
134
135
  }
135
136
  const json = text ? JSON.parse(text) : [];
136
137
  return unwrapEntityList(ctx.className, json);
@@ -150,7 +151,7 @@ export function createCollection(ctx, initialOptions, mapRow) {
150
151
  });
151
152
  const text = await res.text();
152
153
  if (!res.ok) {
153
- throw new Error(`List failed ${res.status}: ${text.slice(0, 200)}`);
154
+ throw new QuadroHttpError(res.status, text);
154
155
  }
155
156
  const json = text ? JSON.parse(text) : [];
156
157
  return unwrapEntityList(ctx.className, json);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadrokit/client",
3
- "version": "0.3.13",
3
+ "version": "0.3.15",
4
4
  "description": "Typed 4D REST client and catalog code generator for QuadroKit",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -33,7 +33,7 @@
33
33
  "generate:fixture": "bun run src/cli.ts generate --url file://../../assets/catalog.json --out ../../.quadrokit/generated-demo"
34
34
  },
35
35
  "dependencies": {
36
- "@quadrokit/shared": "^0.3.13",
36
+ "@quadrokit/shared": "^0.3.15",
37
37
  "undici": "^6.21.0"
38
38
  },
39
39
  "peerDependencies": {