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