@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 +47 -0
- package/package.json +34 -0
- package/src/cli.ts +82 -0
- package/src/generate/codegen.ts +317 -0
- package/src/index.ts +5 -0
- package/src/runtime/collection.ts +135 -0
- package/src/runtime/data-class.ts +156 -0
- package/src/runtime/datastore.ts +25 -0
- package/src/runtime/errors.ts +11 -0
- package/src/runtime/http.ts +64 -0
- package/src/runtime/index.ts +23 -0
- package/src/runtime/paths.ts +42 -0
- package/src/runtime/query.ts +104 -0
- package/src/runtime/unwrap.ts +33 -0
- package/tsconfig.json +9 -0
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,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
|
+
}
|