@quadrokit/client 0.1.0

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 ADDED
@@ -0,0 +1,47 @@
1
+ # `@quadrokit/client`
2
+
3
+ Typed **4D REST** helper: small runtime (`fetch` with `credentials: 'include'`, paging, `$select` / `$expand`) plus a **code generator** that turns a catalog JSON into `.quadrokit/generated`.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add @quadrokit/client
9
+ ```
10
+
11
+ ## CLI: `quadrokit-client`
12
+
13
+ After install, the binary name is **`quadrokit-client`** (not the scoped package name):
14
+
15
+ ```bash
16
+ bunx quadrokit-client generate \
17
+ --url 'http://localhost:7080/rest/$catalog' \
18
+ --token YOUR_TOKEN \
19
+ --out .quadrokit/generated
20
+ ```
21
+
22
+ - **`--url`**: HTTP(S) catalog URL or `file:///absolute/path/to/catalog.json`
23
+ - **`--token`**: optional `Authorization: Bearer …` for protected catalog endpoints
24
+ - **`--out`**: output directory (default `.quadrokit/generated`)
25
+
26
+ ## Generated output
27
+
28
+ - `types.gen.ts` — entity interfaces and `*Path` unions for `select`
29
+ - `client.gen.ts` — `createClient({ baseURL })` with dataclasses, `authentify.login` when present in catalog, and `rpc()` escape hatch
30
+ - `meta.json` — catalog `__NAME`, `sessionCookieName` hint
31
+
32
+ ## Runtime imports
33
+
34
+ Apps and generated code import low-level pieces from:
35
+
36
+ ```ts
37
+ import { QuadroHttp, type SelectedEntity } from '@quadrokit/client/runtime';
38
+ ```
39
+
40
+ The main entry re-exports the runtime barrel.
41
+
42
+ ## Dev in this monorepo
43
+
44
+ ```bash
45
+ bun run typecheck
46
+ bun run src/cli.ts generate --url file://../../assets/catalog.json --out ../../.quadrokit/generated
47
+ ```
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@quadrokit/client",
3
+ "version": "0.1.0",
4
+ "description": "Typed 4D REST client and catalog code generator for QuadroKit",
5
+ "type": "module",
6
+ "bin": {
7
+ "quadrokit-client": "./src/cli.ts"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.ts",
12
+ "import": "./src/index.ts"
13
+ },
14
+ "./runtime": {
15
+ "types": "./src/runtime/index.ts",
16
+ "import": "./src/runtime/index.ts"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "generate:fixture": "bun run src/cli.ts generate --url file://../../assets/catalog.json --out ../../.quadrokit/generated-demo"
22
+ },
23
+ "dependencies": {
24
+ "@quadrokit/shared": "workspace:*"
25
+ },
26
+ "peerDependencies": {
27
+ "typescript": ">=5.4"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "^1.3.11",
31
+ "@types/node": "^25.5.0",
32
+ "typescript": "^5.9.3"
33
+ }
34
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bun
2
+ import { fileURLToPath } from 'node:url'
3
+ import type { CatalogJson } from '@quadrokit/shared'
4
+ import { writeGenerated } from './generate/codegen.js'
5
+
6
+ function parseArgs(argv: string[]): {
7
+ command: string
8
+ url?: string
9
+ token?: string
10
+ out: string
11
+ } {
12
+ let command = ''
13
+ let url: string | undefined
14
+ let token: string | undefined
15
+ let out = '.quadrokit/generated'
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i]
19
+ if (a === '--url' && argv[i + 1]) {
20
+ url = argv[++i]
21
+ } else if (a === '--token' && argv[i + 1]) {
22
+ token = argv[++i]
23
+ } else if (a === '--out' && argv[i + 1]) {
24
+ out = argv[++i]
25
+ } else if (!a.startsWith('-') && !command) {
26
+ command = a
27
+ }
28
+ }
29
+
30
+ return { command, url, token, out }
31
+ }
32
+
33
+ async function loadCatalog(url: string, token?: string): Promise<CatalogJson> {
34
+ if (url.startsWith('file:')) {
35
+ const path = fileURLToPath(url)
36
+ const file = Bun.file(path)
37
+ const text = await file.text()
38
+ return JSON.parse(text) as CatalogJson
39
+ }
40
+
41
+ const headers: Record<string, string> = {
42
+ Accept: 'application/json',
43
+ }
44
+ if (token) {
45
+ headers.Authorization = `Bearer ${token}`
46
+ }
47
+
48
+ const res = await fetch(url, { headers })
49
+ if (!res.ok) {
50
+ const t = await res.text()
51
+ throw new Error(`Failed to fetch catalog (${res.status}): ${t.slice(0, 500)}`)
52
+ }
53
+ return res.json() as Promise<CatalogJson>
54
+ }
55
+
56
+ async function main() {
57
+ const argv = process.argv.slice(2)
58
+ const { command, url, token, out } = parseArgs(argv)
59
+
60
+ if (command !== 'generate') {
61
+ console.error(`Usage: quadrokit-client generate --url <catalog_url> [--token <token>] [--out <dir>]
62
+
63
+ Examples:
64
+ quadrokit-client generate --url http://localhost:7080/rest/\\$catalog --token secret
65
+ quadrokit-client generate --url file://./assets/catalog.json --out .quadrokit/generated
66
+ `)
67
+ process.exit(1)
68
+ }
69
+ if (!url) {
70
+ console.error('Error: --url is required for generate.')
71
+ process.exit(1)
72
+ }
73
+
74
+ const catalog = await loadCatalog(url, token)
75
+ await writeGenerated(out, catalog)
76
+ console.error(`Wrote generated client to ${out}`)
77
+ }
78
+
79
+ main().catch((e) => {
80
+ console.error(e)
81
+ process.exit(1)
82
+ })
@@ -0,0 +1,317 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { CatalogAttribute, CatalogDataClass, CatalogJson } from '@quadrokit/shared'
4
+ import { sessionCookieName } from '@quadrokit/shared'
5
+
6
+ function map4dType(attr: CatalogAttribute): string {
7
+ const t = attr.type ?? 'unknown'
8
+ switch (t) {
9
+ case 'long':
10
+ case 'word':
11
+ return 'number'
12
+ case 'string':
13
+ return 'string'
14
+ case 'bool':
15
+ return 'boolean'
16
+ case 'number':
17
+ return 'number'
18
+ case 'date':
19
+ return 'string'
20
+ case 'duration':
21
+ return 'string | number'
22
+ case 'image':
23
+ return 'string | null'
24
+ case 'object':
25
+ return 'Record<string, unknown>'
26
+ default: {
27
+ if (attr.kind === 'relatedEntity') {
28
+ return `${t} | null`
29
+ }
30
+ if (attr.kind === 'relatedEntities' || attr.kind === 'calculated') {
31
+ return 'unknown'
32
+ }
33
+ return 'unknown'
34
+ }
35
+ }
36
+ }
37
+
38
+ function selectionToEntity(selectionType: string): string {
39
+ if (selectionType.endsWith('Selection')) {
40
+ return selectionType.slice(0, -'Selection'.length)
41
+ }
42
+ return selectionType
43
+ }
44
+
45
+ function navigableRelations(dc: CatalogDataClass): { attr: string; targetClass: string }[] {
46
+ const out: { attr: string; targetClass: string }[] = []
47
+ for (const a of dc.attributes ?? []) {
48
+ if (a.kind === 'relatedEntities') {
49
+ out.push({
50
+ attr: a.name,
51
+ targetClass: selectionToEntity(a.type ?? 'unknown'),
52
+ })
53
+ }
54
+ if (a.kind === 'calculated' && a.behavior === 'relatedEntities' && a.type) {
55
+ out.push({ attr: a.name, targetClass: selectionToEntity(a.type) })
56
+ }
57
+ }
58
+ return out
59
+ }
60
+
61
+ function relationTargets(dc: CatalogDataClass): Record<string, string> {
62
+ const m: Record<string, string> = {}
63
+ for (const a of dc.attributes ?? []) {
64
+ if (a.kind === 'relatedEntity' && a.type) {
65
+ m[a.name] = a.type
66
+ }
67
+ }
68
+ return m
69
+ }
70
+
71
+ function keyNames(dc: CatalogDataClass): string[] {
72
+ const k = dc.key?.map((x) => x.name) ?? ['ID']
73
+ return k.length ? k : ['ID']
74
+ }
75
+
76
+ function collectPaths(
77
+ dc: CatalogDataClass,
78
+ byName: Map<string, CatalogDataClass>,
79
+ maxDepth: number
80
+ ): string[] {
81
+ const paths = new Set<string>()
82
+ const visit = (current: CatalogDataClass, prefix: string, depth: number) => {
83
+ for (const a of current.attributes ?? []) {
84
+ if (a.kind === 'storage' || a.kind === 'calculated' || a.kind === 'alias') {
85
+ const p = prefix ? `${prefix}.${a.name}` : a.name
86
+ paths.add(p)
87
+ }
88
+ if (a.kind === 'relatedEntity' && a.type && depth < maxDepth) {
89
+ const child = byName.get(a.type)
90
+ const base = prefix ? `${prefix}.${a.name}` : a.name
91
+ paths.add(base)
92
+ if (child) {
93
+ visit(child, base, depth + 1)
94
+ }
95
+ }
96
+ }
97
+ }
98
+ visit(dc, '', 0)
99
+ return [...paths].sort()
100
+ }
101
+
102
+ function emitInterface(dc: CatalogDataClass, _byName: Map<string, CatalogDataClass>): string {
103
+ const lines: string[] = []
104
+ lines.push(`export interface ${dc.name} {`)
105
+ for (const a of dc.attributes ?? []) {
106
+ if (a.kind === 'relatedEntities') {
107
+ continue
108
+ }
109
+ if (a.kind === 'calculated' && a.behavior === 'relatedEntities') {
110
+ continue
111
+ }
112
+ const opt = a.not_null || a.identifying ? '' : '?'
113
+ const ts = map4dType(a)
114
+ lines.push(` ${a.name}${opt}: ${ts};`)
115
+ }
116
+ lines.push('}')
117
+ return lines.join('\n')
118
+ }
119
+
120
+ function emitTypes(catalog: CatalogJson): string {
121
+ const classes = catalog.dataClasses?.filter((d) => d.exposed) ?? []
122
+ const byName = new Map(classes.map((c) => [c.name, c]))
123
+ const interfaces = classes.map((c) => emitInterface(c, byName))
124
+ const pathTypes = classes.map((c) => {
125
+ const paths = collectPaths(c, byName, 2)
126
+ const name = `${c.name}Path`
127
+ const body = paths.length ? paths.map((p) => `'${p}'`).join('\n | ') : 'never'
128
+ return `export type ${name} =\n | ${body};`
129
+ })
130
+ return `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n\n${interfaces.join('\n\n')}\n\n${pathTypes.join('\n\n')}\n`
131
+ }
132
+
133
+ function emitClient(catalog: CatalogJson): string {
134
+ const classes = catalog.dataClasses?.filter((d) => d.exposed) ?? []
135
+ const dbName = catalog.__NAME ?? 'default'
136
+ const hasAuthentify = catalog.methods?.some(
137
+ (m) => m.name === 'authentify' && m.applyTo === 'dataStore'
138
+ )
139
+
140
+ const keyNamesRecord = Object.fromEntries(classes.map((x) => [x.name, keyNames(x)] as const))
141
+
142
+ const imports = `/* eslint-disable */\n/* Auto-generated by @quadrokit/client — do not edit */\n\nimport {\n QuadroHttp,\n makeDataClassApi,\n attachRelatedApis,\n callDatastorePath,\n type CollectionHandle,\n type CollectionOptions,\n type SelectedEntity,\n} from '@quadrokit/client/runtime';\n`
143
+
144
+ const typeImports = classes.map((c) => c.name).join(', ')
145
+ const pathImports = classes.map((c) => `${c.name}Path`).join(', ')
146
+ const header = `${imports}\nimport type { ${typeImports}, ${pathImports} } from './types.gen.js';\n\n`
147
+
148
+ const metaExport = `
149
+ export const quadrokitCatalogMeta = {
150
+ __NAME: ${JSON.stringify(dbName)},
151
+ sessionCookieName: ${JSON.stringify(sessionCookieName(catalog))},
152
+ } as const;
153
+ `
154
+
155
+ const configInterface = `
156
+ export interface QuadroClientConfig {
157
+ baseURL: string;
158
+ fetchImpl?: typeof fetch;
159
+ defaultHeaders?: Record<string, string>;
160
+ }
161
+ `
162
+
163
+ const mapFunctions = classes
164
+ .map((c) => {
165
+ const nav = navigableRelations(c)
166
+ const relMap = JSON.stringify(relationTargets(c))
167
+ return ` function map${c.name}Row(http: QuadroHttp, raw: unknown): ${c.name} {
168
+ const row = raw as ${c.name};
169
+ const pk = (row as { ID?: number }).ID ?? (row as { id?: number }).id;
170
+ attachRelatedApis(
171
+ raw,
172
+ {
173
+ http,
174
+ parentClass: '${c.name}',
175
+ parentId: pk as number,
176
+ relationMap: ${relMap},
177
+ keyNames: ${JSON.stringify(keyNamesRecord)},
178
+ },
179
+ ${JSON.stringify(nav)},
180
+ );
181
+ return row;
182
+ }`
183
+ })
184
+ .join('\n\n')
185
+
186
+ const classBranches = classes
187
+ .map((c) => {
188
+ const relMap = JSON.stringify(relationTargets(c))
189
+ const kn = JSON.stringify(keyNames(c))
190
+ const pathsType = `${c.name}Path`
191
+ return ` ${c.name}: (() => {
192
+ const cfg = {
193
+ http,
194
+ className: '${c.name}',
195
+ relationMap: ${relMap},
196
+ keyNames: ${kn},
197
+ } as const;
198
+ const api = makeDataClassApi<${c.name}>(cfg);
199
+ return {
200
+ all<S extends readonly ${pathsType}[] = readonly []>(
201
+ options?: CollectionOptions & { select?: S },
202
+ ): CollectionHandle<S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>> {
203
+ const inner = api.all(options as CollectionOptions);
204
+ return {
205
+ ...inner,
206
+ delete: inner.delete.bind(inner),
207
+ release: inner.release.bind(inner),
208
+ get length() {
209
+ return inner.length;
210
+ },
211
+ [Symbol.asyncIterator]() {
212
+ const it = inner[Symbol.asyncIterator]();
213
+ return {
214
+ async next() {
215
+ const n = await it.next();
216
+ if (!n.done && n.value) {
217
+ map${c.name}Row(http, n.value);
218
+ }
219
+ return n;
220
+ },
221
+ };
222
+ },
223
+ } as CollectionHandle<S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>>;
224
+ },
225
+ async get<S extends readonly ${pathsType}[] = readonly []>(
226
+ id: string | number,
227
+ options?: { select?: S },
228
+ ): Promise<(S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>) | null> {
229
+ const entity = await api.get(id, options);
230
+ if (entity) {
231
+ map${c.name}Row(http, entity);
232
+ }
233
+ return entity as (S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>) | null;
234
+ },
235
+ delete: (id: string | number) => api.delete(id),
236
+ query<S extends readonly ${pathsType}[] = readonly []>(
237
+ filter: string,
238
+ options?: CollectionOptions & { params?: unknown[]; select?: S },
239
+ ): CollectionHandle<S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>> {
240
+ const inner = api.query(filter, options as CollectionOptions);
241
+ return {
242
+ ...inner,
243
+ delete: inner.delete.bind(inner),
244
+ release: inner.release.bind(inner),
245
+ get length() {
246
+ return inner.length;
247
+ },
248
+ [Symbol.asyncIterator]() {
249
+ const it = inner[Symbol.asyncIterator]();
250
+ return {
251
+ async next() {
252
+ const n = await it.next();
253
+ if (!n.done && n.value) {
254
+ map${c.name}Row(http, n.value);
255
+ }
256
+ return n;
257
+ },
258
+ };
259
+ },
260
+ } as CollectionHandle<S['length'] extends 0 ? ${c.name} : SelectedEntity<${c.name}, S>>;
261
+ },
262
+ };
263
+ })(),`
264
+ })
265
+ .join('\n')
266
+
267
+ const authentifyBlock = hasAuthentify
268
+ ? `
269
+ authentify: {
270
+ login: (body: { email: string; password: string }) =>
271
+ callDatastorePath(http, ['authentify', 'login'], { body }),
272
+ },`
273
+ : ''
274
+
275
+ const body = `
276
+ export function createClient(config: QuadroClientConfig) {
277
+ const http = new QuadroHttp({
278
+ baseURL: config.baseURL,
279
+ fetchImpl: config.fetchImpl,
280
+ defaultHeaders: config.defaultHeaders,
281
+ });
282
+
283
+ ${mapFunctions}
284
+
285
+ return {${authentifyBlock}
286
+ ${classBranches}
287
+ rpc: (segments: string[], init?: { method?: 'GET' | 'POST'; body?: unknown }) =>
288
+ callDatastorePath(http, segments, init),
289
+ sessionCookieName: quadrokitCatalogMeta.sessionCookieName,
290
+ _http: http,
291
+ };
292
+ }
293
+
294
+ export type QuadroClient = ReturnType<typeof createClient>;
295
+ `
296
+
297
+ return header + metaExport + configInterface + body
298
+ }
299
+
300
+ export async function writeGenerated(outDir: string, catalog: CatalogJson): Promise<void> {
301
+ await mkdir(outDir, { recursive: true })
302
+ await writeFile(path.join(outDir, 'types.gen.ts'), emitTypes(catalog), 'utf8')
303
+ await writeFile(path.join(outDir, 'client.gen.ts'), emitClient(catalog), 'utf8')
304
+ await writeFile(
305
+ path.join(outDir, 'meta.json'),
306
+ JSON.stringify(
307
+ {
308
+ __NAME: catalog.__NAME,
309
+ sessionCookieName: sessionCookieName(catalog),
310
+ generatedAt: new Date().toISOString(),
311
+ },
312
+ null,
313
+ 2
314
+ ),
315
+ 'utf8'
316
+ )
317
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Runtime and types for QuadroKit 4D REST clients.
3
+ * Generated `createClient` lives in `.quadrokit/generated/client.gen.ts` after `generate`.
4
+ */
5
+ export * from './runtime/index.js'
@@ -0,0 +1,135 @@
1
+ import type { QuadroHttp } from './http.js'
2
+ import { buildListSearchParams, type ListQueryParams } from './query.js'
3
+ import { unwrapEntityList } from './unwrap.js'
4
+
5
+ export interface CollectionContext {
6
+ http: QuadroHttp
7
+ className: string
8
+ relationMap: Record<string, string>
9
+ /** Primary key attribute names (first wins for simple path). */
10
+ keyNames: readonly string[]
11
+ path: string
12
+ /** Optional entity-set URL from a previous response for `release()`. */
13
+ entitySetUrl?: string
14
+ }
15
+
16
+ export interface CollectionOptions extends ListQueryParams {
17
+ page?: number
18
+ pageSize?: number
19
+ select?: readonly string[]
20
+ }
21
+
22
+ export type MapRow<R> = (raw: unknown) => R
23
+
24
+ function primaryKeyFromRow(row: unknown, keyNames: readonly string[]): string | number | undefined {
25
+ if (!row || typeof row !== 'object') {
26
+ return undefined
27
+ }
28
+ const o = row as Record<string, unknown>
29
+ for (const k of keyNames) {
30
+ if (k in o && o[k] != null) {
31
+ return o[k] as string | number
32
+ }
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ /**
38
+ * Lazy paged collection: async iteration loads further pages; tracks PKs for `delete()`.
39
+ */
40
+ export function createCollection<R>(
41
+ ctx: CollectionContext,
42
+ initialOptions: CollectionOptions,
43
+ mapRow: MapRow<R>
44
+ ): CollectionHandle<R> {
45
+ const seenIds = new Set<string | number>()
46
+ let entitySetUrl = ctx.entitySetUrl
47
+
48
+ async function fetchPage(page: number): Promise<unknown[]> {
49
+ const qs = buildListSearchParams(ctx.className, { ...initialOptions, page }, ctx.relationMap)
50
+ const res = await ctx.http.request(`${ctx.path}${qs}`)
51
+ const text = await res.text()
52
+ if (!res.ok) {
53
+ throw new Error(`List failed ${res.status}: ${text.slice(0, 200)}`)
54
+ }
55
+ const loc = res.headers.get('Content-Location') ?? res.headers.get('Location')
56
+ if (loc) {
57
+ entitySetUrl = loc.startsWith('http')
58
+ ? loc
59
+ : `${ctx.http.baseURL}${loc.startsWith('/') ? '' : '/'}${loc}`
60
+ }
61
+ const json = text ? JSON.parse(text) : []
62
+ return unwrapEntityList(ctx.className, json)
63
+ }
64
+
65
+ async function* iteratePages(): AsyncGenerator<R, void, undefined> {
66
+ let page = initialOptions.page ?? 1
67
+ const pageSize = initialOptions.pageSize ?? 50
68
+ while (true) {
69
+ const rows = await fetchPage(page)
70
+ if (!rows.length) {
71
+ break
72
+ }
73
+ for (const raw of rows) {
74
+ const mapped = mapRow(raw)
75
+ const pk = primaryKeyFromRow(raw, ctx.keyNames)
76
+ if (pk !== undefined) {
77
+ seenIds.add(pk)
78
+ }
79
+ yield mapped
80
+ }
81
+ if (rows.length < pageSize) {
82
+ break
83
+ }
84
+ page += 1
85
+ }
86
+ }
87
+
88
+ const iterable: AsyncIterable<R> = {
89
+ [Symbol.asyncIterator]() {
90
+ return iteratePages()[Symbol.asyncIterator]()
91
+ },
92
+ }
93
+
94
+ const handle: CollectionHandle<R> = {
95
+ ...iterable,
96
+ async delete(): Promise<void> {
97
+ const ids = [...seenIds]
98
+ if (!ids.length) {
99
+ return
100
+ }
101
+ await Promise.all(
102
+ ids.map((id) =>
103
+ ctx.http.void(`${ctx.className}(${encodeURIComponent(String(id))})`, {
104
+ method: 'DELETE',
105
+ })
106
+ )
107
+ )
108
+ seenIds.clear()
109
+ },
110
+ async release(): Promise<void> {
111
+ if (entitySetUrl) {
112
+ try {
113
+ await ctx.http.void(entitySetUrl.replace(ctx.http.baseURL, '') || entitySetUrl, {
114
+ method: 'DELETE',
115
+ })
116
+ } catch {
117
+ // Some servers ignore release; swallow
118
+ }
119
+ entitySetUrl = undefined
120
+ }
121
+ },
122
+ get length(): number {
123
+ return seenIds.size
124
+ },
125
+ }
126
+
127
+ return handle
128
+ }
129
+
130
+ export type CollectionHandle<R> = AsyncIterable<R> & {
131
+ delete(): Promise<void>
132
+ release(): Promise<void>
133
+ /** Number of entities seen while iterating (used after partial iteration). */
134
+ readonly length: number
135
+ }
@@ -0,0 +1,156 @@
1
+ import { type CollectionHandle, type CollectionOptions, createCollection } from './collection.js'
2
+ import type { QuadroHttp } from './http.js'
3
+ import { buildEntityParams } from './query.js'
4
+ import { unwrapEntity, unwrapEntityList } from './unwrap.js'
5
+
6
+ export interface DataClassRuntimeConfig {
7
+ http: QuadroHttp
8
+ className: string
9
+ /** relation attribute name → target class name for $expand */
10
+ relationMap: Record<string, string>
11
+ keyNames: readonly string[]
12
+ }
13
+
14
+ export function makeDataClassApi<R = unknown>(cfg: DataClassRuntimeConfig) {
15
+ const path = `/${cfg.className}`
16
+
17
+ return {
18
+ all<S extends readonly string[] = readonly []>(
19
+ options?: CollectionOptions & { select?: S }
20
+ ): CollectionHandle<S extends readonly never[] ? R : R> {
21
+ return createCollection(
22
+ {
23
+ http: cfg.http,
24
+ className: cfg.className,
25
+ relationMap: cfg.relationMap,
26
+ keyNames: cfg.keyNames,
27
+ path,
28
+ },
29
+ options ?? {},
30
+ (raw) => raw as R
31
+ )
32
+ },
33
+
34
+ async get<S extends readonly string[] = readonly []>(
35
+ id: string | number,
36
+ options?: { select?: S }
37
+ ): Promise<R | null> {
38
+ const qs = options?.select?.length
39
+ ? buildEntityParams(cfg.className, options.select, cfg.relationMap)
40
+ : ''
41
+ const key = encodeURIComponent(String(id))
42
+ const body = await cfg.http.json<unknown>(`${cfg.className}(${key})${qs}`)
43
+ return unwrapEntity<R>(cfg.className, body) as R | null
44
+ },
45
+
46
+ async delete(id: string | number): Promise<boolean> {
47
+ const key = encodeURIComponent(String(id))
48
+ try {
49
+ await cfg.http.void(`${cfg.className}(${key})`, { method: 'DELETE' })
50
+ return true
51
+ } catch {
52
+ return false
53
+ }
54
+ },
55
+
56
+ query<S extends readonly string[] = readonly []>(
57
+ filter: string,
58
+ options?: CollectionOptions & { params?: unknown[]; select?: S }
59
+ ): CollectionHandle<S extends readonly never[] ? R : R> {
60
+ const paramFilter = substituteQueryParams(filter, options?.params)
61
+ return createCollection(
62
+ {
63
+ http: cfg.http,
64
+ className: cfg.className,
65
+ relationMap: cfg.relationMap,
66
+ keyNames: cfg.keyNames,
67
+ path,
68
+ },
69
+ { ...options, filter: paramFilter },
70
+ (raw) => raw as R
71
+ )
72
+ },
73
+ }
74
+ }
75
+
76
+ function substituteQueryParams(expr: string, params?: unknown[]): string {
77
+ if (!params?.length) {
78
+ return expr
79
+ }
80
+ let out = expr
81
+ params.forEach((p, i) => {
82
+ const placeholder = `:${i + 1}`
83
+ const lit = formatFilterLiteral(p)
84
+ out = out.split(placeholder).join(lit)
85
+ })
86
+ return out
87
+ }
88
+
89
+ function formatFilterLiteral(p: unknown): string {
90
+ if (p === null || p === undefined) {
91
+ return 'null'
92
+ }
93
+ if (typeof p === 'number') {
94
+ return String(p)
95
+ }
96
+ if (typeof p === 'boolean') {
97
+ return p ? 'true' : 'false'
98
+ }
99
+ const s = String(p).replace(/'/g, "''")
100
+ return `'${s}'`
101
+ }
102
+
103
+ export interface EntityNavigationConfig {
104
+ http: QuadroHttp
105
+ parentClass: string
106
+ parentId: string | number
107
+ relationMap: Record<string, string>
108
+ keyNames: Record<string, readonly string[]>
109
+ }
110
+
111
+ /** Nested collection under an entity, e.g. `Agency(1)/todayBookings`. */
112
+ export function makeRelatedCollectionApi(
113
+ cfg: EntityNavigationConfig,
114
+ attributeName: string,
115
+ targetClassName: string
116
+ ) {
117
+ const basePath = `/${cfg.parentClass}(${encodeURIComponent(String(cfg.parentId))})/${attributeName}`
118
+
119
+ return {
120
+ list<S extends readonly string[] = readonly []>(
121
+ options?: CollectionOptions & { select?: S }
122
+ ): CollectionHandle<unknown> {
123
+ return createCollection(
124
+ {
125
+ http: cfg.http,
126
+ className: targetClassName,
127
+ relationMap: cfg.relationMap,
128
+ keyNames: cfg.keyNames[targetClassName] ?? ['ID'],
129
+ path: basePath,
130
+ },
131
+ options ?? {},
132
+ (raw) => raw
133
+ )
134
+ },
135
+ }
136
+ }
137
+
138
+ export function attachRelatedApis(
139
+ row: unknown,
140
+ cfg: EntityNavigationConfig,
141
+ relations: readonly { attr: string; targetClass: string }[]
142
+ ): void {
143
+ if (!row || typeof row !== 'object') {
144
+ return
145
+ }
146
+ const obj = row as Record<string, unknown>
147
+ for (const { attr, targetClass } of relations) {
148
+ Object.defineProperty(obj, attr, {
149
+ enumerable: false,
150
+ configurable: true,
151
+ value: makeRelatedCollectionApi(cfg, attr, targetClass),
152
+ })
153
+ }
154
+ }
155
+
156
+ export { unwrapEntityList }
@@ -0,0 +1,25 @@
1
+ import type { QuadroHttp } from './http.js'
2
+
3
+ /** Call nested datastore paths like `authentify/login` (POST by default). */
4
+ export async function callDatastorePath(
5
+ http: QuadroHttp,
6
+ segments: readonly string[],
7
+ init?: { method?: 'GET' | 'POST'; body?: unknown }
8
+ ): Promise<unknown> {
9
+ const path = `/${segments.join('/')}`
10
+ const method = init?.method ?? 'POST'
11
+ if (method === 'GET') {
12
+ return http.json(path, { method: 'GET' })
13
+ }
14
+ return http.json(path, {
15
+ method: 'POST',
16
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
17
+ })
18
+ }
19
+
20
+ export function createDatastoreNamespace(
21
+ _http: QuadroHttp,
22
+ tree: Record<string, unknown>
23
+ ): Record<string, unknown> {
24
+ return tree
25
+ }
@@ -0,0 +1,11 @@
1
+ export class QuadroHttpError extends Error {
2
+ readonly status: number
3
+ readonly body: string
4
+
5
+ constructor(status: number, body: string) {
6
+ super(`Quadro HTTP ${status}: ${body.slice(0, 200)}`)
7
+ this.name = 'QuadroHttpError'
8
+ this.status = status
9
+ this.body = body
10
+ }
11
+ }
@@ -0,0 +1,64 @@
1
+ import { QuadroHttpError } from './errors.js'
2
+
3
+ export interface QuadroFetchOptions {
4
+ baseURL: string
5
+ fetchImpl?: typeof fetch
6
+ /** Extra headers on every request (e.g. Authorization for generate). */
7
+ defaultHeaders?: Record<string, string>
8
+ }
9
+
10
+ export function normalizeBaseURL(baseURL: string): string {
11
+ return baseURL.replace(/\/$/, '')
12
+ }
13
+
14
+ export class QuadroHttp {
15
+ constructor(private readonly opts: QuadroFetchOptions) {}
16
+
17
+ get baseURL(): string {
18
+ return normalizeBaseURL(this.opts.baseURL)
19
+ }
20
+
21
+ async request(path: string, init: RequestInit = {}): Promise<Response> {
22
+ const url = path.startsWith('http')
23
+ ? path
24
+ : `${this.baseURL}${path.startsWith('/') ? '' : '/'}${path}`
25
+ const headers = new Headers(init.headers)
26
+ if (!headers.has('Accept')) {
27
+ headers.set('Accept', 'application/json')
28
+ }
29
+ if (init.body !== undefined && !headers.has('Content-Type')) {
30
+ headers.set('Content-Type', 'application/json')
31
+ }
32
+ for (const [k, v] of Object.entries(this.opts.defaultHeaders ?? {})) {
33
+ if (!headers.has(k)) {
34
+ headers.set(k, v)
35
+ }
36
+ }
37
+ const fetchFn = this.opts.fetchImpl ?? fetch
38
+ return fetchFn(url, {
39
+ credentials: 'include',
40
+ ...init,
41
+ headers,
42
+ })
43
+ }
44
+
45
+ async json<T>(path: string, init: RequestInit = {}): Promise<T> {
46
+ const res = await this.request(path, init)
47
+ const text = await res.text()
48
+ if (!res.ok) {
49
+ throw new QuadroHttpError(res.status, text)
50
+ }
51
+ if (!text) {
52
+ return undefined as T
53
+ }
54
+ return JSON.parse(text) as T
55
+ }
56
+
57
+ async void(path: string, init: RequestInit = {}): Promise<void> {
58
+ const res = await this.request(path, init)
59
+ if (!res.ok) {
60
+ const text = await res.text()
61
+ throw new QuadroHttpError(res.status, text)
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,23 @@
1
+ export {
2
+ type CollectionContext,
3
+ type CollectionHandle,
4
+ type CollectionOptions,
5
+ createCollection,
6
+ } from './collection.js'
7
+ export {
8
+ attachRelatedApis,
9
+ type DataClassRuntimeConfig,
10
+ type EntityNavigationConfig,
11
+ makeDataClassApi,
12
+ makeRelatedCollectionApi,
13
+ } from './data-class.js'
14
+ export { callDatastorePath, createDatastoreNamespace } from './datastore.js'
15
+ export { QuadroHttpError } from './errors.js'
16
+ export {
17
+ normalizeBaseURL,
18
+ type QuadroFetchOptions,
19
+ QuadroHttp,
20
+ } from './http.js'
21
+ export type { Paths1, SelectedEntity } from './paths.js'
22
+ export { buildListSearchParams, type ListQueryParams } from './query.js'
23
+ export { unwrapEntity, unwrapEntityList } from './unwrap.js'
@@ -0,0 +1,42 @@
1
+ /** Type-level helpers for `$select` dot paths (compile-time only). */
2
+
3
+ type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
4
+ k: infer I
5
+ ) => void
6
+ ? I
7
+ : never
8
+
9
+ type PickOne<T, H extends string> = H extends keyof T & string
10
+ ? Pick<T, H>
11
+ : H extends `${infer K}.${infer R}`
12
+ ? K extends keyof T & string
13
+ ? NonNullable<T[K]> extends infer U
14
+ ? U extends object
15
+ ? { [P in K]: PickOne<U, R> }
16
+ : never
17
+ : never
18
+ : never
19
+ : never
20
+
21
+ declare const selectBrand: unique symbol
22
+
23
+ /** Entity narrowed to selected paths (branded for nominal distinction). */
24
+ export type SelectedEntity<T, S extends readonly string[]> =
25
+ UnionToIntersection<
26
+ S[number] extends infer H ? (H extends string ? PickOne<T, H> : never) : never
27
+ > extends infer O
28
+ ? O extends object
29
+ ? O & { readonly [selectBrand]: S }
30
+ : never
31
+ : never
32
+
33
+ /** Flat union of first-level and nested dot paths (codegen emits concrete unions). */
34
+ export type Paths1<T> = T extends object
35
+ ? {
36
+ [K in keyof T & string]: NonNullable<T[K]> extends infer U
37
+ ? U extends object
38
+ ? K | `${K}.${Paths1<U>}`
39
+ : K
40
+ : K
41
+ }[keyof T & string]
42
+ : never
@@ -0,0 +1,104 @@
1
+ /** Build 4D REST query params ($skip, $top, $filter, $expand, $select). */
2
+
3
+ export interface ListQueryParams {
4
+ page?: number
5
+ pageSize?: number
6
+ /** Dot-paths like `manager.name` → $expand + nested $select where possible. */
7
+ select?: readonly string[]
8
+ /** OData-style filter string (4D REST). */
9
+ filter?: string
10
+ orderby?: string
11
+ }
12
+
13
+ export function buildListSearchParams(
14
+ className: string,
15
+ opts: ListQueryParams,
16
+ _relationMap: Record<string, string>
17
+ ): string {
18
+ const params = new URLSearchParams()
19
+ const page = opts.page ?? 1
20
+ const pageSize = opts.pageSize ?? 50
21
+ const skip = (page - 1) * pageSize
22
+ if (skip > 0) {
23
+ params.set('$skip', String(skip))
24
+ }
25
+ params.set('$top', String(pageSize))
26
+ if (opts.filter) {
27
+ params.set('$filter', opts.filter)
28
+ }
29
+ if (opts.orderby) {
30
+ params.set('$orderby', opts.orderby)
31
+ }
32
+ const { expand, selectRoot } = buildExpandAndSelect(className, opts.select ?? [], _relationMap)
33
+ if (selectRoot.length) {
34
+ params.set('$select', selectRoot.join(','))
35
+ }
36
+ if (expand.length) {
37
+ params.set('$expand', expand.join(','))
38
+ }
39
+ const q = params.toString()
40
+ return q ? `?${q}` : ''
41
+ }
42
+
43
+ /** Query string for a single entity (only $select / $expand, no paging). */
44
+ export function buildEntityParams(
45
+ className: string,
46
+ select: readonly string[] | undefined,
47
+ _relationMap: Record<string, string>
48
+ ): string {
49
+ if (!select?.length) {
50
+ return ''
51
+ }
52
+ const { expand, selectRoot } = buildExpandAndSelect(className, select, _relationMap)
53
+ const params = new URLSearchParams()
54
+ if (selectRoot.length) {
55
+ params.set('$select', selectRoot.join(','))
56
+ }
57
+ if (expand.length) {
58
+ params.set('$expand', expand.join(','))
59
+ }
60
+ const q = params.toString()
61
+ return q ? `?${q}` : ''
62
+ }
63
+
64
+ function buildExpandAndSelect(
65
+ _className: string,
66
+ paths: readonly string[],
67
+ _relationMap: Record<string, string>
68
+ ): { expand: string[]; selectRoot: string[] } {
69
+ if (!paths.length) {
70
+ return { expand: [], selectRoot: [] }
71
+ }
72
+ const rootSelect = new Set<string>()
73
+ const expandParts: string[] = []
74
+ const byRel = new Map<string, Set<string>>()
75
+
76
+ for (const p of paths) {
77
+ const dot = p.indexOf('.')
78
+ if (dot === -1) {
79
+ rootSelect.add(p)
80
+ continue
81
+ }
82
+ const rel = p.slice(0, dot)
83
+ const rest = p.slice(dot + 1)
84
+ if (!byRel.has(rel)) {
85
+ byRel.set(rel, new Set())
86
+ }
87
+ byRel.get(rel)!.add(rest)
88
+ }
89
+
90
+ for (const [rel, nested] of byRel) {
91
+ const nestedList = [...nested]
92
+ const inner = nestedList.length ? `($select=${nestedList.join(',')})` : ''
93
+ // Use navigation property name (4D / OData $expand), not the target class name.
94
+ expandParts.push(`${rel}${inner}`)
95
+ if (!rootSelect.has(rel)) {
96
+ rootSelect.add(rel)
97
+ }
98
+ }
99
+
100
+ return {
101
+ expand: expandParts,
102
+ selectRoot: [...rootSelect],
103
+ }
104
+ }
@@ -0,0 +1,33 @@
1
+ /** Normalize 4D REST JSON (array or `{ ClassName: [...] }`). */
2
+
3
+ export function unwrapEntityList<T>(className: string, body: unknown): T[] {
4
+ if (Array.isArray(body)) {
5
+ return body as T[]
6
+ }
7
+ if (body && typeof body === 'object' && className in body) {
8
+ const v = (body as Record<string, unknown>)[className]
9
+ if (Array.isArray(v)) {
10
+ return v as T[]
11
+ }
12
+ }
13
+ if (body && typeof body === 'object' && '__ENTITIES' in body) {
14
+ const v = (body as { __ENTITIES?: unknown }).__ENTITIES
15
+ if (Array.isArray(v)) {
16
+ return v as T[]
17
+ }
18
+ }
19
+ return []
20
+ }
21
+
22
+ export function unwrapEntity<T>(className: string, body: unknown): T | null {
23
+ if (body == null) {
24
+ return null
25
+ }
26
+ if (Array.isArray(body)) {
27
+ return (body[0] as T) ?? null
28
+ }
29
+ if (typeof body === 'object' && className in body) {
30
+ return (body as Record<string, T>)[className] ?? null
31
+ }
32
+ return body as T
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "noEmit": true,
6
+ "types": ["bun", "node"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }