zitejs 0.9.94 → 0.9.95
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/cjs/auth/index.d.ts +23 -4
- package/dist/cjs/auth/index.js +8 -0
- package/dist/cjs/auth-server/index.d.ts +3 -2
- package/dist/cjs/backend/index.d.ts +39 -15
- package/dist/cjs/backend/index.js +10 -4
- package/dist/cjs/bundle/index.js +28 -4
- package/dist/cjs/check/index.js +57 -3
- package/dist/cjs/runtime/index.d.ts +57 -22
- package/dist/cjs/sync/lib.js +314 -74
- package/dist/esm/auth/index.d.ts +23 -4
- package/dist/esm/auth/index.js +8 -0
- package/dist/esm/auth-server/index.d.ts +3 -2
- package/dist/esm/backend/index.d.ts +39 -15
- package/dist/esm/backend/index.js +10 -4
- package/dist/esm/bundle/index.js +28 -4
- package/dist/esm/check/index.js +57 -3
- package/dist/esm/runtime/index.d.ts +57 -22
- package/dist/esm/sync/lib.js +314 -74
- package/package.json +1 -1
package/dist/esm/sync/lib.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parse } from "@babel/parser";
|
|
2
2
|
const AUTH_USERS_TABLE_ID = "zite_user";
|
|
3
|
+
/** What a field's value looks like when a record is READ back. */
|
|
3
4
|
const FIELD_TYPE_MAP = {
|
|
4
5
|
single_line_text: "string",
|
|
5
6
|
long_text: "string",
|
|
@@ -17,16 +18,72 @@ const FIELD_TYPE_MAP = {
|
|
|
17
18
|
checkbox: "boolean",
|
|
18
19
|
date: "string | null",
|
|
19
20
|
datetime: "string | null",
|
|
20
|
-
attachments: "
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
attachments: "ZiteAttachment[]",
|
|
22
|
+
// base-runner normalizes both of these to an array on write and stores them
|
|
23
|
+
// that way, so a read never produces a bare string.
|
|
24
|
+
linked_record: "string[]",
|
|
25
|
+
user: "string[]",
|
|
23
26
|
lookup: "unknown",
|
|
27
|
+
rollup: "unknown",
|
|
24
28
|
autonumber: "number",
|
|
25
29
|
source: "string",
|
|
26
30
|
formula: "unknown",
|
|
27
31
|
created_at: "string",
|
|
28
32
|
updated_at: "string",
|
|
33
|
+
updated_by: "string | null",
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Field types whose WRITE shape differs from their read shape — the API accepts
|
|
37
|
+
* looser input than it stores.
|
|
38
|
+
*/
|
|
39
|
+
const FIELD_INPUT_TYPE_MAP = {
|
|
40
|
+
attachments: "ZiteAttachmentInput[]",
|
|
41
|
+
linked_record: "string | string[] | null",
|
|
42
|
+
user: "string | string[] | null",
|
|
29
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* Computed and system-managed fields. base-runner rejects a write to any of
|
|
46
|
+
* these outright (`createRecordDataSchema` in `trpc/schemas/records.ts`), so
|
|
47
|
+
* they are omitted from the generated input type rather than typed and then
|
|
48
|
+
* refused at runtime.
|
|
49
|
+
*/
|
|
50
|
+
const READ_ONLY_FIELD_TYPES = new Set([
|
|
51
|
+
"lookup",
|
|
52
|
+
"rollup",
|
|
53
|
+
"source",
|
|
54
|
+
"formula",
|
|
55
|
+
"autonumber",
|
|
56
|
+
"updated_by",
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* Emitted once per `db.ts`. The old inline `{ url: string; name?: string }` was
|
|
60
|
+
* wrong in both directions: `name` is not a property the API returns (it is
|
|
61
|
+
* `filename` / `originalName`), and the other six read properties were missing.
|
|
62
|
+
*/
|
|
63
|
+
const ATTACHMENT_TYPES = [
|
|
64
|
+
"/** An attachment as returned when reading a record. */",
|
|
65
|
+
"export type ZiteAttachment = {",
|
|
66
|
+
" /** Original filename as uploaded. */",
|
|
67
|
+
" filename: string;",
|
|
68
|
+
" /** Display name — may differ from `filename`. */",
|
|
69
|
+
" originalName: string;",
|
|
70
|
+
" /** File size in bytes. */",
|
|
71
|
+
" size: number;",
|
|
72
|
+
" mimeType: string;",
|
|
73
|
+
" /** Publicly accessible URL. */",
|
|
74
|
+
" url: string;",
|
|
75
|
+
" /** ISO timestamp of the upload. */",
|
|
76
|
+
" uploadedAt: string;",
|
|
77
|
+
" metadata?: Record<string, unknown>;",
|
|
78
|
+
"};",
|
|
79
|
+
"",
|
|
80
|
+
"/** What you may write to an attachments field: a URL, a URL with a name, or an attachment you read earlier. */",
|
|
81
|
+
"export type ZiteAttachmentInput =",
|
|
82
|
+
" | string",
|
|
83
|
+
" | { url: string; filename?: string }",
|
|
84
|
+
" | ZiteAttachment;",
|
|
85
|
+
"",
|
|
86
|
+
];
|
|
30
87
|
const MAX_SELECT_OPTIONS = 100;
|
|
31
88
|
const NUMBER_FORMAT_EXAMPLES = {
|
|
32
89
|
local: "1,000,000.50",
|
|
@@ -69,7 +126,7 @@ function keepValidSdkName(sdkName) {
|
|
|
69
126
|
return undefined;
|
|
70
127
|
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(sdkName) ? sdkName : undefined;
|
|
71
128
|
}
|
|
72
|
-
function tsTypeForSchemaField(def) {
|
|
129
|
+
function tsTypeForSchemaField(def, variant = "read") {
|
|
73
130
|
if (def.type === "single_select" || def.type === "multiple_select") {
|
|
74
131
|
const options = def.template
|
|
75
132
|
?.options;
|
|
@@ -84,11 +141,17 @@ function tsTypeForSchemaField(def) {
|
|
|
84
141
|
return union;
|
|
85
142
|
}
|
|
86
143
|
}
|
|
144
|
+
if (variant === "write" && FIELD_INPUT_TYPE_MAP[def.type]) {
|
|
145
|
+
return FIELD_INPUT_TYPE_MAP[def.type];
|
|
146
|
+
}
|
|
87
147
|
if (FIELD_TYPE_MAP[def.type])
|
|
88
148
|
return FIELD_TYPE_MAP[def.type];
|
|
89
|
-
|
|
149
|
+
// Not `string`. A field type this generator doesn't know about is not a
|
|
150
|
+
// string just because most of them are — asserting one lets `record.value`
|
|
151
|
+
// silently pass as `string` everywhere it flows.
|
|
152
|
+
return "unknown";
|
|
90
153
|
}
|
|
91
|
-
function fieldJsdoc(schemaField, table) {
|
|
154
|
+
function fieldJsdoc(schemaField, table, schema) {
|
|
92
155
|
const def = schemaField.definition;
|
|
93
156
|
const parts = [];
|
|
94
157
|
if (table.primaryFieldId === schemaField.id)
|
|
@@ -102,8 +165,12 @@ function fieldJsdoc(schemaField, table) {
|
|
|
102
165
|
}
|
|
103
166
|
if (def.type === "linked_record") {
|
|
104
167
|
const tpl = def.template;
|
|
105
|
-
if (tpl.tableId)
|
|
106
|
-
|
|
168
|
+
if (tpl.tableId) {
|
|
169
|
+
// The SDK name, not the raw `tbl...` id — the id appears nowhere the
|
|
170
|
+
// reader can act on, while the SDK name is the property on `zite`.
|
|
171
|
+
const linked = schema?.tables.find((t) => t.id === tpl.tableId);
|
|
172
|
+
parts.push(`Links to ${linked ? linked.sdkName : tpl.tableId}`);
|
|
173
|
+
}
|
|
107
174
|
if (tpl.allowMultiple === false)
|
|
108
175
|
parts.push("Single record only");
|
|
109
176
|
}
|
|
@@ -236,12 +303,13 @@ function generateSentinelSdkTypes() {
|
|
|
236
303
|
" | { created: number }",
|
|
237
304
|
" | { created: 0; preview: true; wouldCreate: number };",
|
|
238
305
|
"",
|
|
306
|
+
// Must match `AuthUser` in zitejs/runtime and `User` in zitejs/auth.
|
|
239
307
|
"export interface ZiteAuthUser {",
|
|
240
308
|
" id: string;",
|
|
241
309
|
" name: string;",
|
|
242
310
|
" email: string;",
|
|
243
|
-
" firstName
|
|
244
|
-
" lastName
|
|
311
|
+
" firstName?: string;",
|
|
312
|
+
" lastName?: string;",
|
|
245
313
|
" image: string | null;",
|
|
246
314
|
"}",
|
|
247
315
|
"",
|
|
@@ -311,13 +379,29 @@ export function generateDbTs(schema) {
|
|
|
311
379
|
lines.push("//");
|
|
312
380
|
lines.push("// Table client methods (all take a single params object):");
|
|
313
381
|
lines.push("// .findAll({ filters?, sort?, offset?, limit?, fields? }) → { records: T[], hasMore: boolean }");
|
|
314
|
-
lines.push("// filters: { fieldName: value } for equality, { fieldName: {
|
|
382
|
+
lines.push("// filters: { fieldName: value } for equality, or { fieldName: { <op>: value } }");
|
|
383
|
+
lines.push("// operators: contains, not, in, notIn, lt, lte, gt, gte");
|
|
384
|
+
lines.push('// `not: null` means "is set"; `in`/`notIn` with an empty array apply NO filter');
|
|
315
385
|
lines.push("// sort: [{ field: 'fieldName', direction: 'asc' | 'desc' }]");
|
|
386
|
+
lines.push("// limit defaults to 500, max 5000; offset is a row count (a number)");
|
|
316
387
|
lines.push("// .findOne({ id?, filters?, fields? }) → T | undefined");
|
|
317
388
|
lines.push("// .create({ record }) → T");
|
|
318
389
|
lines.push("// .update({ id, record }) → { id: string, fields: Partial<T> }");
|
|
319
|
-
lines.push("// .delete({ id }) → { id: string }");
|
|
390
|
+
lines.push("// .delete({ id }) → { success: true, id: string }");
|
|
320
391
|
lines.push("// .bulkCreate({ records, matchOn? }) → { success: boolean, records: T[] }");
|
|
392
|
+
lines.push("// up to 500 records per call; matchOn upserts on those fields");
|
|
393
|
+
lines.push("//");
|
|
394
|
+
lines.push("// Reading these types:");
|
|
395
|
+
lines.push("// SDK names are stable identifiers — they do NOT change when a table or field is");
|
|
396
|
+
lines.push('// renamed. Each carries its current user-facing name in quotes (// "Task Name").');
|
|
397
|
+
lines.push("// Where the two differ the field was renamed, and the quoted name is what it means");
|
|
398
|
+
lines.push("// now — treat it as the source of truth.");
|
|
399
|
+
lines.push("// Values are RAW: dates are ISO strings, durations are seconds. Format them for");
|
|
400
|
+
lines.push("// display yourself, following the display config in each field's comment.");
|
|
401
|
+
lines.push("// PERCENT fields are stored as decimals — 0.5 is 50%, 1.0 is 100%.");
|
|
402
|
+
lines.push("// LINKED RECORD fields hold UUID record ids from findOne/findAll — never invent one.");
|
|
403
|
+
lines.push("// Computed fields (formula, rollup, lookup, autonumber) are absent from the *Input");
|
|
404
|
+
lines.push("// types — writing to one is rejected.");
|
|
321
405
|
lines.push("//");
|
|
322
406
|
lines.push("// Zite auth users:");
|
|
323
407
|
lines.push("// zite.auth.findAllUsers({ appIds?, filter?, filters?, sort?, limit?, offset? })");
|
|
@@ -329,6 +413,9 @@ export function generateDbTs(schema) {
|
|
|
329
413
|
lines.push("// Raw SQL (read-only SELECTs for aggregates, joins, multi-table queries):");
|
|
330
414
|
lines.push('// zite.sql({ query: "SELECT ...", params?: [...] })');
|
|
331
415
|
lines.push("// → { rows: Record<string, unknown>[], columns, rowCount, truncated }");
|
|
416
|
+
lines.push("// Capped at 2000 rows — `truncated: true` means there were more, so paginate in");
|
|
417
|
+
lines.push("// SQL rather than assuming you got everything. Queries time out after 10s.");
|
|
418
|
+
lines.push("// columns[].name is the SDK name; columns[].originalName is the user-facing one.");
|
|
332
419
|
lines.push("//");
|
|
333
420
|
lines.push("// SQL guidelines:");
|
|
334
421
|
lines.push("// - Use SDK names for tables (PascalCase) and fields (camelCase)");
|
|
@@ -341,16 +428,18 @@ export function generateDbTs(schema) {
|
|
|
341
428
|
lines.push(...buildLinkTableComments(schema));
|
|
342
429
|
lines.push("import { createTableClient, createSqlClient, createNotificationsClient, createAuthClient } from 'zitejs/runtime';");
|
|
343
430
|
lines.push("");
|
|
431
|
+
lines.push(...ATTACHMENT_TYPES);
|
|
344
432
|
for (const table of tables) {
|
|
345
433
|
const className = toPascalCase(table.sdkName);
|
|
346
434
|
const recordType = `${className}RecordType`;
|
|
435
|
+
lines.push(`/** A ${table.sdkName} record as it is read back. */`);
|
|
347
436
|
lines.push(`export type ${recordType} = {`);
|
|
348
437
|
lines.push(" id: string;");
|
|
349
438
|
for (const field of table.fields) {
|
|
350
439
|
if (field.sdkName === "id")
|
|
351
440
|
continue;
|
|
352
441
|
const tsType = tsTypeForSchemaField(field.definition);
|
|
353
|
-
const jsdoc = fieldJsdoc(field, table);
|
|
442
|
+
const jsdoc = fieldJsdoc(field, table, schema);
|
|
354
443
|
if (jsdoc) {
|
|
355
444
|
lines.push(` /** ${jsdoc} */`);
|
|
356
445
|
}
|
|
@@ -358,12 +447,24 @@ export function generateDbTs(schema) {
|
|
|
358
447
|
}
|
|
359
448
|
lines.push("};");
|
|
360
449
|
lines.push("");
|
|
450
|
+
// The write shape is a separate type, not `Partial<RecordType>`: computed
|
|
451
|
+
// fields can't be written at all, and several field types accept looser
|
|
452
|
+
// input than they store (an attachments field takes a URL string; a linked
|
|
453
|
+
// record takes one id or many).
|
|
454
|
+
const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_FIELD_TYPES.has(f.definition.type));
|
|
455
|
+
lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
|
|
456
|
+
lines.push(`export type ${className}RecordInput = {`);
|
|
457
|
+
for (const field of writableFields) {
|
|
458
|
+
lines.push(` ${field.sdkName}: ${tsTypeForSchemaField(field.definition, "write")};`);
|
|
459
|
+
}
|
|
460
|
+
lines.push("};");
|
|
461
|
+
lines.push("");
|
|
361
462
|
}
|
|
362
463
|
lines.push(...generateSentinelSdkTypes());
|
|
363
464
|
lines.push("export const zite = {");
|
|
364
465
|
for (const table of tables) {
|
|
365
466
|
const className = toPascalCase(table.sdkName);
|
|
366
|
-
lines.push(` ${table.sdkName}: createTableClient<${className}RecordType>('${className}'),`);
|
|
467
|
+
lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
|
|
367
468
|
}
|
|
368
469
|
lines.push(` sql: createSqlClient(),`);
|
|
369
470
|
lines.push(` notifications: createNotificationsClient(),`);
|
|
@@ -372,7 +473,7 @@ export function generateDbTs(schema) {
|
|
|
372
473
|
lines.push("");
|
|
373
474
|
return lines.join("\n");
|
|
374
475
|
}
|
|
375
|
-
function
|
|
476
|
+
function inspectEndpointFile(source) {
|
|
376
477
|
try {
|
|
377
478
|
const ast = parse(source, {
|
|
378
479
|
sourceType: "module",
|
|
@@ -381,38 +482,87 @@ function detectStreamEnabled(source) {
|
|
|
381
482
|
const defaultExport = ast.program.body.find((n) => n.type === "ExportDefaultDeclaration" &&
|
|
382
483
|
n.declaration.type === "CallExpression");
|
|
383
484
|
if (!defaultExport || defaultExport.type !== "ExportDefaultDeclaration")
|
|
384
|
-
return false;
|
|
485
|
+
return { isEndpoint: false, stream: false };
|
|
385
486
|
const call = defaultExport.declaration;
|
|
386
487
|
if (call.type !== "CallExpression" || call.arguments.length === 0)
|
|
387
|
-
return false;
|
|
488
|
+
return { isEndpoint: false, stream: false };
|
|
388
489
|
const arg = call.arguments[0];
|
|
490
|
+
// `createEndpoint(config)` — anything else defaulting out of src/api/ is
|
|
491
|
+
// not an endpoint we can type a caller for.
|
|
389
492
|
if (arg.type !== "ObjectExpression")
|
|
390
|
-
return false;
|
|
493
|
+
return { isEndpoint: false, stream: false };
|
|
391
494
|
const streamProp = arg.properties.find((p) => p.type === "ObjectProperty" &&
|
|
392
495
|
((p.key.type === "Identifier" && p.key.name === "stream") ||
|
|
393
496
|
(p.key.type === "StringLiteral" && p.key.value === "stream")));
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
streamProp.value.value === true
|
|
497
|
+
const stream = !!streamProp &&
|
|
498
|
+
streamProp.type === "ObjectProperty" &&
|
|
499
|
+
streamProp.value.type === "BooleanLiteral" &&
|
|
500
|
+
streamProp.value.value === true;
|
|
501
|
+
return { isEndpoint: true, stream };
|
|
398
502
|
}
|
|
399
503
|
catch {
|
|
400
|
-
|
|
504
|
+
// A file that doesn't parse can't be typed either. Leaving it out keeps
|
|
505
|
+
// one broken file from taking the whole generated module down with it.
|
|
506
|
+
return { isEndpoint: false, stream: false };
|
|
401
507
|
}
|
|
402
508
|
}
|
|
509
|
+
/**
|
|
510
|
+
* Reserved words can't be `const` names. The route is the filename either way,
|
|
511
|
+
* so only the generated identifier needs escaping — `default.ts` becomes
|
|
512
|
+
* `export const _default`, still reachable as `api.default` because reserved
|
|
513
|
+
* words are legal object keys.
|
|
514
|
+
*/
|
|
515
|
+
const RESERVED_IDENTIFIERS = new Set([
|
|
516
|
+
"await", "break", "case", "catch", "class", "const", "continue", "debugger",
|
|
517
|
+
"default", "delete", "do", "else", "enum", "export", "extends", "false",
|
|
518
|
+
"finally", "for", "function", "if", "implements", "import", "in",
|
|
519
|
+
"instanceof", "interface", "let", "new", "null", "package", "private",
|
|
520
|
+
"protected", "public", "return", "static", "super", "switch", "this",
|
|
521
|
+
"throw", "true", "try", "typeof", "var", "void", "while", "with", "yield",
|
|
522
|
+
]);
|
|
523
|
+
const toSafeIdentifier = (camelName) => RESERVED_IDENTIFIERS.has(camelName) ? `_${camelName}` : camelName;
|
|
403
524
|
export function generateApiTs(endpointFiles) {
|
|
404
525
|
if (!endpointFiles || endpointFiles.length === 0)
|
|
405
526
|
return null;
|
|
406
527
|
const endpoints = [];
|
|
528
|
+
const seenIdents = new Set();
|
|
529
|
+
const skipped = [];
|
|
407
530
|
for (const file of endpointFiles) {
|
|
408
531
|
const fileName = typeof file === "string" ? file : file.fileName;
|
|
409
532
|
const content = typeof file === "string" ? undefined : file.content;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
533
|
+
// `foo.d.ts` -> baseName `foo.d`, which imports nothing and camelCases to
|
|
534
|
+
// `fooD`. Declaration files are never endpoints.
|
|
535
|
+
if (fileName.endsWith(".d.ts"))
|
|
536
|
+
continue;
|
|
537
|
+
// Callers that pass bare filenames can't be filtered — every in-tree caller
|
|
538
|
+
// passes content, and so does the backend's migration assembler.
|
|
539
|
+
const shape = content ? inspectEndpointFile(content) : undefined;
|
|
540
|
+
if (shape && !shape.isEndpoint) {
|
|
541
|
+
skipped.push(fileName);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const baseName = fileName.replace(/\.(ts|js)$/, "");
|
|
545
|
+
const key = toCamelCase(baseName);
|
|
546
|
+
const ident = toSafeIdentifier(key);
|
|
547
|
+
// Distinct files can collide on one identifier (`send-email.ts` and
|
|
548
|
+
// `send_email.ts` both camelCase to `sendEmail`), which used to emit the
|
|
549
|
+
// same `export const` twice. Input is sorted by every caller, so first-wins
|
|
550
|
+
// is stable across runs.
|
|
551
|
+
if (seenIdents.has(ident)) {
|
|
552
|
+
skipped.push(`${fileName} (name collides with '${ident}')`);
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
seenIdents.add(ident);
|
|
556
|
+
endpoints.push({
|
|
557
|
+
baseName,
|
|
558
|
+
key,
|
|
559
|
+
ident,
|
|
560
|
+
pascal: toPascalCase(key),
|
|
561
|
+
stream: shape?.stream ?? false,
|
|
562
|
+
});
|
|
415
563
|
}
|
|
564
|
+
if (endpoints.length === 0)
|
|
565
|
+
return null;
|
|
416
566
|
const hasStreaming = endpoints.some((e) => e.stream);
|
|
417
567
|
const lines = [
|
|
418
568
|
"// Auto-generated by zitejs generate. Do not edit manually.",
|
|
@@ -422,30 +572,39 @@ export function generateApiTs(endpointFiles) {
|
|
|
422
572
|
: "import { createCaller } from 'zitejs/caller';",
|
|
423
573
|
"",
|
|
424
574
|
];
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
575
|
+
if (skipped.length > 0) {
|
|
576
|
+
lines.push("// Not endpoints, so no callers were generated for them:");
|
|
577
|
+
for (const name of skipped)
|
|
578
|
+
lines.push(`// ${name}`);
|
|
579
|
+
lines.push("");
|
|
580
|
+
}
|
|
581
|
+
for (const { pascal, baseName } of endpoints) {
|
|
430
582
|
lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
|
|
431
583
|
}
|
|
432
584
|
lines.push("");
|
|
433
|
-
for (const {
|
|
585
|
+
for (const { baseName, ident, pascal, stream } of endpoints) {
|
|
434
586
|
lines.push(`type _${pascal}Cfg = typeof _${pascal}Ep;`);
|
|
435
|
-
|
|
587
|
+
// A caller sends the schema's INPUT type, not its output: a field with a
|
|
588
|
+
// `.default()` or a transform is optional to send and guaranteed on the
|
|
589
|
+
// other side. Reading it off `execute` gave the output type, so callers
|
|
590
|
+
// were forced to pass values the schema exists to supply. `NonNullable`
|
|
591
|
+
// because `inputSchema` is optional — without it the conditional always
|
|
592
|
+
// took the fallback branch.
|
|
593
|
+
lines.push(`export type ${pascal}InputType = NonNullable<_${pascal}Cfg['inputSchema']> extends { _input: infer I } ? I : Parameters<_${pascal}Cfg['execute']>[0]['input'];`);
|
|
436
594
|
lines.push(`export type ${pascal}OutputType = Awaited<ReturnType<_${pascal}Cfg['execute']>>;`);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
595
|
+
// The route is the filename, not the camelCased identifier: the bundler
|
|
596
|
+
// keys endpoints by filename base and the runner looks up `/api/:name`
|
|
597
|
+
// against those keys, so `send-email.ts` is reachable at `/api/send-email`.
|
|
598
|
+
// Calling it `sendEmail` here made every hyphenated or snake_cased endpoint
|
|
599
|
+
// a 404.
|
|
600
|
+
const caller = stream ? "createStreamingCaller" : "createCaller";
|
|
601
|
+
lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
|
|
443
602
|
lines.push("");
|
|
444
603
|
}
|
|
445
604
|
lines.push("");
|
|
446
605
|
lines.push("export const api = {");
|
|
447
|
-
for (const {
|
|
448
|
-
lines.push(` ${
|
|
606
|
+
for (const { key, ident } of endpoints) {
|
|
607
|
+
lines.push(key === ident ? ` ${ident},` : ` ${key}: ${ident},`);
|
|
449
608
|
}
|
|
450
609
|
lines.push("};");
|
|
451
610
|
lines.push("");
|
|
@@ -475,8 +634,8 @@ const AIRTABLE_FIELD_TYPE_MAP = {
|
|
|
475
634
|
dateTime: "string",
|
|
476
635
|
singleSelect: "string",
|
|
477
636
|
multipleSelects: "string[]",
|
|
478
|
-
multipleAttachments: "
|
|
479
|
-
multipleRecordLinks: "string
|
|
637
|
+
multipleAttachments: "AirtableAttachment[]",
|
|
638
|
+
multipleRecordLinks: "string[]",
|
|
480
639
|
singleCollaborator: "{ id: string; email: string; name?: string }",
|
|
481
640
|
multipleCollaborators: "Array<{ id: string; email: string; name?: string }>",
|
|
482
641
|
// Read-only fields
|
|
@@ -493,7 +652,49 @@ const AIRTABLE_FIELD_TYPE_MAP = {
|
|
|
493
652
|
externalSyncSource: "unknown",
|
|
494
653
|
aiText: "string",
|
|
495
654
|
};
|
|
496
|
-
|
|
655
|
+
/**
|
|
656
|
+
* Field types whose WRITE shape is looser than what Airtable returns.
|
|
657
|
+
* Attachments come back as full `AirtableAttachment` objects but are written as
|
|
658
|
+
* `{ url, filename? }`; a link field returns an array but accepts one id.
|
|
659
|
+
*/
|
|
660
|
+
const AIRTABLE_FIELD_INPUT_TYPE_MAP = {
|
|
661
|
+
multipleAttachments: "Array<{ url: string; filename?: string }>",
|
|
662
|
+
multipleRecordLinks: "string | string[]",
|
|
663
|
+
singleCollaborator: "{ id: string } | { email: string }",
|
|
664
|
+
multipleCollaborators: "Array<{ id: string } | { email: string }>",
|
|
665
|
+
};
|
|
666
|
+
/** Mirrors `airtable/lib/attachment.d.ts`. Every property but `thumbnails` is present on a read. */
|
|
667
|
+
const AIRTABLE_ATTACHMENT_TYPE = [
|
|
668
|
+
"export type AirtableAttachment = {",
|
|
669
|
+
" id: string;",
|
|
670
|
+
" url: string;",
|
|
671
|
+
" filename: string;",
|
|
672
|
+
" /** Size in bytes. */",
|
|
673
|
+
" size: number;",
|
|
674
|
+
" /** MIME type. */",
|
|
675
|
+
" type: string;",
|
|
676
|
+
" thumbnails?: {",
|
|
677
|
+
" small: { url: string; width: number; height: number };",
|
|
678
|
+
" large: { url: string; width: number; height: number };",
|
|
679
|
+
" full: { url: string; width: number; height: number };",
|
|
680
|
+
" };",
|
|
681
|
+
"};",
|
|
682
|
+
"",
|
|
683
|
+
];
|
|
684
|
+
function airtableTsType(field, lock, depth, variant = "read") {
|
|
685
|
+
if (variant === "write" && AIRTABLE_FIELD_INPUT_TYPE_MAP[field.type]) {
|
|
686
|
+
return AIRTABLE_FIELD_INPUT_TYPE_MAP[field.type];
|
|
687
|
+
}
|
|
688
|
+
// A formula or rollup's cell type is whatever its expression produces, and
|
|
689
|
+
// Airtable reports that in the field's `result`. Reading it turns `unknown`
|
|
690
|
+
// — which forces a cast at every use — into the actual type.
|
|
691
|
+
if ((field.type === "formula" || field.type === "rollup") &&
|
|
692
|
+
(depth ?? 0) < 5) {
|
|
693
|
+
const result = field.config?.result;
|
|
694
|
+
if (result?.type && result.type !== field.type) {
|
|
695
|
+
return airtableTsType({ ...field, type: result.type, config: undefined }, lock, (depth ?? 0) + 1, variant);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
497
698
|
const choices = field.config?.choices;
|
|
498
699
|
if ((field.type === "singleSelect" || field.type === "multipleSelects") &&
|
|
499
700
|
choices &&
|
|
@@ -540,7 +741,7 @@ const READ_ONLY_AIRTABLE_FIELDS = new Set([
|
|
|
540
741
|
"externalSyncSource",
|
|
541
742
|
"aiText",
|
|
542
743
|
]);
|
|
543
|
-
function airtableFieldJsdoc(field, table) {
|
|
744
|
+
function airtableFieldJsdoc(field, table, lock) {
|
|
544
745
|
const parts = [];
|
|
545
746
|
if (table.primaryFieldId === field.id)
|
|
546
747
|
parts.push("Primary field");
|
|
@@ -548,8 +749,10 @@ function airtableFieldJsdoc(field, table) {
|
|
|
548
749
|
parts.push("Read-only; do not write");
|
|
549
750
|
if (field.type === "multipleRecordLinks") {
|
|
550
751
|
const linkedTableId = field.config?.linkedTableId;
|
|
551
|
-
if (linkedTableId)
|
|
552
|
-
|
|
752
|
+
if (linkedTableId) {
|
|
753
|
+
const linked = lock?.tables.find((t) => t.id === linkedTableId);
|
|
754
|
+
parts.push(`Links to ${linked ? linked.sdkName : linkedTableId}`);
|
|
755
|
+
}
|
|
553
756
|
if (field.config?.prefersSingleRecordLink)
|
|
554
757
|
parts.push("Single record only");
|
|
555
758
|
}
|
|
@@ -616,32 +819,62 @@ export function generateAirtableTs(lock) {
|
|
|
616
819
|
"// update({ id, record }) => { id: string, fields: T } | undefined",
|
|
617
820
|
"// delete({ id }) => { id: string }",
|
|
618
821
|
"//",
|
|
822
|
+
"// Reading these types:",
|
|
823
|
+
"// Every field is OPTIONAL. Airtable omits an empty cell from the response",
|
|
824
|
+
"// entirely rather than sending null, so any field can be undefined on any row.",
|
|
825
|
+
"// SDK names are stable identifiers and do not change when a field is renamed —",
|
|
826
|
+
'// each carries its current user-facing name in quotes (// "Task Name"), which is',
|
|
827
|
+
"// the source of truth for what it means.",
|
|
828
|
+
"// Values are RAW; format them for display using each field's comment.",
|
|
829
|
+
"// Fields marked \"Links to X\" hold Airtable record ids (rec...) from",
|
|
830
|
+
"// findOne/findAll — never invent one.",
|
|
831
|
+
"// Read-only fields are absent from the *RecordInput types: Airtable rejects a",
|
|
832
|
+
"// write to a formula, rollup, lookup or autonumber with a 422.",
|
|
833
|
+
"//",
|
|
619
834
|
"// Usage tips:",
|
|
620
835
|
"// - Airtable has a strict rate limit of 5 requests/second per base",
|
|
621
|
-
"// - Use bulkCreate() instead of calling create() in a loop",
|
|
622
|
-
"//
|
|
836
|
+
"// - Use bulkCreate() instead of calling create() in a loop. Pass any number of",
|
|
837
|
+
"// records — it chunks into Airtable's 10-per-request batches for you",
|
|
838
|
+
"// - findAll() offsets are opaque cursor strings from a previous call, NOT row",
|
|
839
|
+
"// counts (unlike zite.<table>.findAll, whose offset IS a number)",
|
|
623
840
|
"// - Always destructure record properties individually in create/update calls",
|
|
624
841
|
"",
|
|
625
842
|
"import { createAirtableClient } from 'zitejs/runtime';",
|
|
626
843
|
"",
|
|
627
844
|
];
|
|
845
|
+
lines.push(...AIRTABLE_ATTACHMENT_TYPE);
|
|
628
846
|
for (const table of lock.tables) {
|
|
629
847
|
const recordType = `${table.sdkName}RecordType`;
|
|
848
|
+
lines.push(`/** A ${table.sdkName} record as it is read back. */`);
|
|
630
849
|
lines.push(`export type ${recordType} = {`);
|
|
631
850
|
lines.push(" id: string;");
|
|
632
851
|
for (const field of table.fields) {
|
|
633
852
|
if (field.sdkName === "id")
|
|
634
853
|
continue;
|
|
635
|
-
const jsdoc = airtableFieldJsdoc(field, table);
|
|
854
|
+
const jsdoc = airtableFieldJsdoc(field, table, lock);
|
|
636
855
|
if (jsdoc) {
|
|
637
856
|
lines.push(` /** ${jsdoc} */`);
|
|
638
857
|
}
|
|
639
858
|
const tsType = airtableTsType(field, lock);
|
|
640
|
-
|
|
859
|
+
// Optional, because Airtable omits a field from the response entirely
|
|
860
|
+
// when its cell is empty — it does not send null. Declaring these
|
|
861
|
+
// required told app code every cell was populated, and `record.notes`
|
|
862
|
+
// typed `string` is `undefined` at runtime on the first blank row.
|
|
863
|
+
lines.push(` ${field.sdkName}?: ${tsType};`);
|
|
641
864
|
}
|
|
642
865
|
lines.push("};");
|
|
643
866
|
lines.push("");
|
|
644
|
-
|
|
867
|
+
// Read-only fields are omitted rather than typed: Airtable rejects a write
|
|
868
|
+
// to a formula, rollup, lookup or autonumber with a 422.
|
|
869
|
+
const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
|
|
870
|
+
lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
|
|
871
|
+
lines.push(`export type ${table.sdkName}RecordInput = {`);
|
|
872
|
+
for (const field of writableFields) {
|
|
873
|
+
lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
|
|
874
|
+
}
|
|
875
|
+
lines.push("};");
|
|
876
|
+
lines.push("");
|
|
877
|
+
lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}, ${table.sdkName}RecordInput>(`);
|
|
645
878
|
lines.push(` '${lock.integrationId}',`);
|
|
646
879
|
lines.push(` '${table.sdkName}',`);
|
|
647
880
|
lines.push(` { tableId: '${table.id}' },`);
|
|
@@ -659,45 +892,52 @@ export function generateBackendWrapperTs(envVarNames = []) {
|
|
|
659
892
|
"// Auto-generated type-narrowing wrapper. Do not edit manually.",
|
|
660
893
|
"// Re-exports createEndpoint with context.user typed to the app User.",
|
|
661
894
|
"",
|
|
662
|
-
"import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext
|
|
895
|
+
"import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteWebhook } from 'zitejs/backend/base';",
|
|
663
896
|
"import type { User } from 'zitejs/auth';",
|
|
664
897
|
"",
|
|
665
|
-
"export type { ZiteSchedule, ZiteWebhook };",
|
|
898
|
+
"export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteWebhook };",
|
|
666
899
|
"",
|
|
667
900
|
"export class ZiteError extends Error {",
|
|
668
|
-
"
|
|
669
|
-
" constructor(
|
|
670
|
-
" super(message);",
|
|
901
|
+
" code: ZiteErrorCode;",
|
|
902
|
+
" constructor(options: { code: ZiteErrorCode; message: string }) {",
|
|
903
|
+
" super(options.message);",
|
|
671
904
|
" this.name = 'ZiteError';",
|
|
672
|
-
" this.
|
|
905
|
+
" this.code = options.code;",
|
|
673
906
|
" }",
|
|
674
907
|
"}",
|
|
675
908
|
"",
|
|
909
|
+
// Narrows `user` to this app's generated User. ZiteScheduledContext is
|
|
910
|
+
// re-exported unchanged — a scheduled fire has no user at all, so there is
|
|
911
|
+
// nothing to narrow.
|
|
676
912
|
'export interface ZiteRequestContext extends Omit<_ZiteRequestContext, "user"> {',
|
|
677
913
|
" user: User;",
|
|
678
914
|
"}",
|
|
679
915
|
"",
|
|
680
|
-
|
|
681
|
-
" user: User;",
|
|
682
|
-
" scheduledAt: string;",
|
|
683
|
-
"}",
|
|
684
|
-
"",
|
|
685
|
-
"type SchemaLike<T> = { _output: T; parse: (data: unknown) => T };",
|
|
916
|
+
"type SchemaLike<TOut, TIn = TOut> = { _output: TOut; _input: TIn; parse: (data: unknown) => TOut };",
|
|
686
917
|
"",
|
|
687
|
-
|
|
918
|
+
// TStream mirrors zitejs/backend/base. Without it `stream: true` endpoints
|
|
919
|
+
// get no `stream` argument here — and this wrapper, not the base module, is
|
|
920
|
+
// what `zitejs/backend` resolves to in every app.
|
|
921
|
+
"export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput> {",
|
|
688
922
|
" description?: string;",
|
|
689
|
-
" inputSchema?: SchemaLike<TInput>;",
|
|
923
|
+
" inputSchema?: SchemaLike<TInput, TRawInput>;",
|
|
690
924
|
" outputSchema?: SchemaLike<TOutput>;",
|
|
691
|
-
" stream?:
|
|
925
|
+
" stream?: TStream;",
|
|
692
926
|
" authenticated?: boolean;",
|
|
693
|
-
" schedule
|
|
927
|
+
" /** When set, the endpoint also fires on this cron schedule. It stays request-callable — declaring one widens `context`, so `context.user` must be null-checked. */",
|
|
928
|
+
" schedule?: TSchedule;",
|
|
694
929
|
" webhook?: ZiteWebhook;",
|
|
695
|
-
" execute: (
|
|
930
|
+
" execute: (",
|
|
931
|
+
" params: {",
|
|
932
|
+
" input: TInput;",
|
|
933
|
+
" context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
|
|
934
|
+
" } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
|
|
935
|
+
" ) => Promise<TOutput> | TOutput;",
|
|
696
936
|
"}",
|
|
697
937
|
"",
|
|
698
|
-
"export function createEndpoint<TInput = unknown, TOutput = unknown>(",
|
|
699
|
-
" config: EndpointConfig<TInput, TOutput>,",
|
|
700
|
-
"): EndpointConfig<TInput, TOutput> {",
|
|
938
|
+
"export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TRawInput = TInput>(",
|
|
939
|
+
" config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput>,",
|
|
940
|
+
"): EndpointConfig<TInput, TOutput, TStream, TSchedule, TRawInput> {",
|
|
701
941
|
" return config;",
|
|
702
942
|
"}",
|
|
703
943
|
"",
|