@quadrokit/client 0.1.0 → 0.2.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/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +69 -0
- package/dist/generate/codegen.d.ts +3 -0
- package/dist/generate/codegen.d.ts.map +1 -0
- package/dist/generate/codegen.js +282 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/{src/index.ts → dist/index.js} +1 -1
- package/dist/runtime/collection.d.ts +29 -0
- package/dist/runtime/collection.d.ts.map +1 -0
- package/dist/runtime/collection.js +94 -0
- package/dist/runtime/data-class.d.ts +42 -0
- package/dist/runtime/data-class.d.ts.map +1 -0
- package/dist/runtime/data-class.js +99 -0
- package/dist/runtime/datastore.d.ts +8 -0
- package/dist/runtime/datastore.d.ts.map +1 -0
- package/dist/runtime/datastore.js +15 -0
- package/dist/runtime/errors.d.ts +6 -0
- package/dist/runtime/errors.d.ts.map +1 -0
- package/dist/runtime/errors.js +10 -0
- package/dist/runtime/http.d.ts +16 -0
- package/dist/runtime/http.d.ts.map +1 -0
- package/dist/runtime/http.js +54 -0
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +7 -0
- package/dist/runtime/paths.d.ts +16 -0
- package/dist/runtime/paths.d.ts.map +1 -0
- package/dist/runtime/paths.js +2 -0
- package/dist/runtime/query.d.ts +14 -0
- package/dist/runtime/query.d.ts.map +1 -0
- package/dist/runtime/query.js +76 -0
- package/dist/runtime/unwrap.d.ts +4 -0
- package/dist/runtime/unwrap.d.ts.map +1 -0
- package/dist/runtime/unwrap.js +31 -0
- package/package.json +13 -6
- package/src/cli.ts +0 -82
- package/src/generate/codegen.ts +0 -317
- package/src/runtime/collection.ts +0 -135
- package/src/runtime/data-class.ts +0 -156
- package/src/runtime/datastore.ts +0 -25
- package/src/runtime/errors.ts +0 -11
- package/src/runtime/http.ts +0 -64
- package/src/runtime/index.ts +0 -23
- package/src/runtime/paths.ts +0 -42
- package/src/runtime/query.ts +0 -104
- package/src/runtime/unwrap.ts +0 -33
- package/tsconfig.json +0 -9
package/src/generate/codegen.ts
DELETED
|
@@ -1,317 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
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 }
|
package/src/runtime/datastore.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
}
|
package/src/runtime/errors.ts
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
}
|
package/src/runtime/http.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
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
|
-
}
|