zitejs 0.9.94 → 0.9.96

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.
@@ -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,79 @@ const FIELD_TYPE_MAP = {
27
28
  checkbox: "boolean",
28
29
  date: "string | null",
29
30
  datetime: "string | null",
30
- attachments: "Array<{ url: string; name?: string }>",
31
- linked_record: "string | string[]",
32
- user: "string | string[]",
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",
39
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",
53
+ };
54
+ /**
55
+ * Computed and system-managed fields, omitted from the generated input type
56
+ * rather than typed and then refused at runtime.
57
+ *
58
+ * Mirrors `COMPUTED_FIELD_TYPES` in the product's own `shared/bases/fields`.
59
+ * The first six are rejected by `createRecordDataSchema` — each has a
60
+ * `refine(val => val == null)`, so a non-null write errors (null is a no-op
61
+ * clear). `created_at` / `updated_at` are refused differently: they have no
62
+ * column at all, so a write is silently dropped, which is the worse outcome to
63
+ * leave typed as writable.
64
+ */
65
+ const READ_ONLY_FIELD_TYPES = new Set([
66
+ "lookup",
67
+ "rollup",
68
+ "source",
69
+ "formula",
70
+ "autonumber",
71
+ "updated_by",
72
+ "created_at",
73
+ "updated_at",
74
+ ]);
75
+ /**
76
+ * Emitted once per `db.ts`. The old inline `{ url: string; name?: string }` was
77
+ * wrong in both directions: `name` is not a property the API returns (it is
78
+ * `filename` / `originalName`), and the other six read properties were missing.
79
+ */
80
+ const ATTACHMENT_TYPES = [
81
+ "/** An attachment as returned when reading a record. */",
82
+ "export type ZiteAttachment = {",
83
+ " /** Original filename as uploaded. */",
84
+ " filename: string;",
85
+ " /** Display name — may differ from `filename`. */",
86
+ " originalName: string;",
87
+ " /** File size in bytes. */",
88
+ " size: number;",
89
+ " mimeType: string;",
90
+ " /** Publicly accessible URL. */",
91
+ " url: string;",
92
+ " /** ISO timestamp of the upload. */",
93
+ " uploadedAt: string;",
94
+ " metadata?: Record<string, unknown>;",
95
+ "};",
96
+ "",
97
+ "/** What you may write to an attachments field: a URL, a URL with a name, or an attachment you read earlier. */",
98
+ "export type ZiteAttachmentInput =",
99
+ " | string",
100
+ " | { url: string; filename?: string }",
101
+ " | ZiteAttachment;",
102
+ "",
103
+ ];
40
104
  const MAX_SELECT_OPTIONS = 100;
41
105
  const NUMBER_FORMAT_EXAMPLES = {
42
106
  local: "1,000,000.50",
@@ -53,6 +117,19 @@ const DURATION_FORMAT_EXAMPLES = {
53
117
  "h:mm:ss.ss": "1:23:03.00",
54
118
  "h:mm:ss.sss": "1:23:03.000",
55
119
  };
120
+ // Naming the format alone ("display as `european` format") doesn't say what to
121
+ // render — these mirror the pickers in the database UI, which is what the user
122
+ // chose from. `local` is the viewer's system locale, so it has no fixed example.
123
+ const DATE_FORMAT_EXAMPLES = {
124
+ long: "January 15, 2024",
125
+ us: "1/15/2024",
126
+ european: "15/01/2024",
127
+ iso: "2024-01-15",
128
+ };
129
+ const withDateFormatExample = (format) => {
130
+ const example = DATE_FORMAT_EXAMPLES[format];
131
+ return example ? `"${format}" format (e.g. ${example})` : `"${format}" format`;
132
+ };
56
133
  function toPascalCase(name) {
57
134
  const pascal = name
58
135
  .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
@@ -79,7 +156,7 @@ function keepValidSdkName(sdkName) {
79
156
  return undefined;
80
157
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(sdkName) ? sdkName : undefined;
81
158
  }
82
- function tsTypeForSchemaField(def) {
159
+ function tsTypeForSchemaField(def, variant = "read") {
83
160
  if (def.type === "single_select" || def.type === "multiple_select") {
84
161
  const options = def.template
85
162
  ?.options;
@@ -94,11 +171,17 @@ function tsTypeForSchemaField(def) {
94
171
  return union;
95
172
  }
96
173
  }
174
+ if (variant === "write" && FIELD_INPUT_TYPE_MAP[def.type]) {
175
+ return FIELD_INPUT_TYPE_MAP[def.type];
176
+ }
97
177
  if (FIELD_TYPE_MAP[def.type])
98
178
  return FIELD_TYPE_MAP[def.type];
99
- return "string";
179
+ // Not `string`. A field type this generator doesn't know about is not a
180
+ // string just because most of them are — asserting one lets `record.value`
181
+ // silently pass as `string` everywhere it flows.
182
+ return "unknown";
100
183
  }
101
- function fieldJsdoc(schemaField, table) {
184
+ function fieldJsdoc(schemaField, table, schema) {
102
185
  const def = schemaField.definition;
103
186
  const parts = [];
104
187
  if (table.primaryFieldId === schemaField.id)
@@ -112,8 +195,12 @@ function fieldJsdoc(schemaField, table) {
112
195
  }
113
196
  if (def.type === "linked_record") {
114
197
  const tpl = def.template;
115
- if (tpl.tableId)
116
- parts.push(`Links to table ${tpl.tableId}`);
198
+ if (tpl.tableId) {
199
+ // The SDK name, not the raw `tbl...` id — the id appears nowhere the
200
+ // reader can act on, while the SDK name is the property on `zite`.
201
+ const linked = schema?.tables.find((t) => t.id === tpl.tableId);
202
+ parts.push(`Links to ${linked ? linked.sdkName : tpl.tableId}`);
203
+ }
117
204
  if (tpl.allowMultiple === false)
118
205
  parts.push("Single record only");
119
206
  }
@@ -121,13 +208,13 @@ function fieldJsdoc(schemaField, table) {
121
208
  if (def.type === "date") {
122
209
  const tpl = def.template;
123
210
  if (tpl.dateFormat)
124
- parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as "${tpl.dateFormat}" format`);
211
+ parts.push(`Date-only field (YYYY-MM-DD string), null when unset, display as ${withDateFormatExample(tpl.dateFormat)}`);
125
212
  }
126
213
  if (def.type === "datetime") {
127
214
  const tpl = def.template;
128
215
  const timeParts = ["Date+time field (ISO 8601 timestamp), null when unset"];
129
216
  if (tpl.dateFormat)
130
- timeParts.push(`date: ${tpl.dateFormat}`);
217
+ timeParts.push(`date: ${withDateFormatExample(tpl.dateFormat)}`);
131
218
  if (tpl.timeFormat)
132
219
  timeParts.push(`time: ${tpl.timeFormat}`);
133
220
  if (tpl.timezone)
@@ -246,12 +333,13 @@ function generateSentinelSdkTypes() {
246
333
  " | { created: number }",
247
334
  " | { created: 0; preview: true; wouldCreate: number };",
248
335
  "",
336
+ // Must match `AuthUser` in zitejs/runtime and `User` in zitejs/auth.
249
337
  "export interface ZiteAuthUser {",
250
338
  " id: string;",
251
339
  " name: string;",
252
340
  " email: string;",
253
- " firstName: string | null;",
254
- " lastName: string | null;",
341
+ " firstName?: string;",
342
+ " lastName?: string;",
255
343
  " image: string | null;",
256
344
  "}",
257
345
  "",
@@ -321,13 +409,30 @@ function generateDbTs(schema) {
321
409
  lines.push("//");
322
410
  lines.push("// Table client methods (all take a single params object):");
323
411
  lines.push("// .findAll({ filters?, sort?, offset?, limit?, fields? }) → { records: T[], hasMore: boolean }");
324
- lines.push("// filters: { fieldName: value } for equality, { fieldName: { contains|gt|lt|gte|lte: value } } for operators");
412
+ lines.push("// filters: { fieldName: value } for equality, or { fieldName: { <op>: value } }");
413
+ lines.push("// operators: contains, not, in, notIn, lt, lte, gt, gte");
414
+ lines.push('// `not: null` means "is set"; `in`/`notIn` with an empty array apply NO filter');
325
415
  lines.push("// sort: [{ field: 'fieldName', direction: 'asc' | 'desc' }]");
416
+ lines.push("// limit defaults to 500, max 2000; offset is a row count (a number)");
326
417
  lines.push("// .findOne({ id?, filters?, fields? }) → T | undefined");
327
418
  lines.push("// .create({ record }) → T");
328
419
  lines.push("// .update({ id, record }) → { id: string, fields: Partial<T> }");
329
- lines.push("// .delete({ id }) → { id: string }");
420
+ lines.push("// .delete({ id }) → { success: true, id: string }");
330
421
  lines.push("// .bulkCreate({ records, matchOn? }) → { success: boolean, records: T[] }");
422
+ lines.push("// up to 100 records per call; matchOn upserts on those fields");
423
+ lines.push("//");
424
+ lines.push("// Reading these types:");
425
+ lines.push("// SDK names are stable identifiers — they do NOT change when a table or field is");
426
+ lines.push('// renamed. Each carries its current user-facing name in quotes (// "Task Name").');
427
+ lines.push("// Where the two differ the field was renamed, and the quoted name is what it means");
428
+ lines.push("// now — treat it as the source of truth.");
429
+ lines.push("// Values are RAW: dates are ISO strings, durations are seconds. Format them for");
430
+ lines.push("// display yourself, following the display config in each field's comment.");
431
+ lines.push("// PERCENT fields are stored as decimals — 0.5 is 50%, 1.0 is 100%.");
432
+ lines.push("// LINKED RECORD fields hold UUID record ids from findOne/findAll — never invent one.");
433
+ lines.push("// Computed and system-managed fields (formula, rollup, lookup, autonumber,");
434
+ lines.push("// created/updated timestamps and more) are absent from the *Input types —");
435
+ lines.push("// a write to one is rejected or silently dropped.");
331
436
  lines.push("//");
332
437
  lines.push("// Zite auth users:");
333
438
  lines.push("// zite.auth.findAllUsers({ appIds?, filter?, filters?, sort?, limit?, offset? })");
@@ -339,6 +444,9 @@ function generateDbTs(schema) {
339
444
  lines.push("// Raw SQL (read-only SELECTs for aggregates, joins, multi-table queries):");
340
445
  lines.push('// zite.sql({ query: "SELECT ...", params?: [...] })');
341
446
  lines.push("// → { rows: Record<string, unknown>[], columns, rowCount, truncated }");
447
+ lines.push("// Capped at 2000 rows — `truncated: true` means there were more, so paginate in");
448
+ lines.push("// SQL rather than assuming you got everything. Queries time out after 10s.");
449
+ lines.push("// columns[].name is the SDK name; columns[].originalName is the underlying", "// database column (or your own SQL alias).");
342
450
  lines.push("//");
343
451
  lines.push("// SQL guidelines:");
344
452
  lines.push("// - Use SDK names for tables (PascalCase) and fields (camelCase)");
@@ -351,16 +459,18 @@ function generateDbTs(schema) {
351
459
  lines.push(...buildLinkTableComments(schema));
352
460
  lines.push("import { createTableClient, createSqlClient, createNotificationsClient, createAuthClient } from 'zitejs/runtime';");
353
461
  lines.push("");
462
+ lines.push(...ATTACHMENT_TYPES);
354
463
  for (const table of tables) {
355
464
  const className = toPascalCase(table.sdkName);
356
465
  const recordType = `${className}RecordType`;
466
+ lines.push(`/** A ${table.sdkName} record as it is read back. */`);
357
467
  lines.push(`export type ${recordType} = {`);
358
468
  lines.push(" id: string;");
359
469
  for (const field of table.fields) {
360
470
  if (field.sdkName === "id")
361
471
  continue;
362
472
  const tsType = tsTypeForSchemaField(field.definition);
363
- const jsdoc = fieldJsdoc(field, table);
473
+ const jsdoc = fieldJsdoc(field, table, schema);
364
474
  if (jsdoc) {
365
475
  lines.push(` /** ${jsdoc} */`);
366
476
  }
@@ -368,12 +478,24 @@ function generateDbTs(schema) {
368
478
  }
369
479
  lines.push("};");
370
480
  lines.push("");
481
+ // The write shape is a separate type, not `Partial<RecordType>`: computed
482
+ // fields can't be written at all, and several field types accept looser
483
+ // input than they store (an attachments field takes a URL string; a linked
484
+ // record takes one id or many).
485
+ const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_FIELD_TYPES.has(f.definition.type));
486
+ lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
487
+ lines.push(`export type ${className}RecordInput = {`);
488
+ for (const field of writableFields) {
489
+ lines.push(` ${field.sdkName}: ${tsTypeForSchemaField(field.definition, "write")};`);
490
+ }
491
+ lines.push("};");
492
+ lines.push("");
371
493
  }
372
494
  lines.push(...generateSentinelSdkTypes());
373
495
  lines.push("export const zite = {");
374
496
  for (const table of tables) {
375
497
  const className = toPascalCase(table.sdkName);
376
- lines.push(` ${table.sdkName}: createTableClient<${className}RecordType>('${className}'),`);
498
+ lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
377
499
  }
378
500
  lines.push(` sql: createSqlClient(),`);
379
501
  lines.push(` notifications: createNotificationsClient(),`);
@@ -382,47 +504,136 @@ function generateDbTs(schema) {
382
504
  lines.push("");
383
505
  return lines.join("\n");
384
506
  }
385
- function detectStreamEnabled(source) {
507
+ const NOT_AN_ENDPOINT = {
508
+ isEndpoint: false,
509
+ hasDefaultExport: false,
510
+ stream: false,
511
+ };
512
+ /** `createEndpoint(...)` anywhere in the expression, through wrappers. */
513
+ function callsCreateEndpoint(node) {
514
+ if (!node || typeof node !== "object")
515
+ return false;
516
+ const n = node;
517
+ if (n.type === "CallExpression" &&
518
+ n.callee?.name ===
519
+ "createEndpoint") {
520
+ return true;
521
+ }
522
+ return Object.values(n).some((v) => Array.isArray(v) ? v.some(callsCreateEndpoint) : callsCreateEndpoint(v));
523
+ }
524
+ /** Unwrap `x satisfies T` / `x as const` to the expression underneath. */
525
+ function unwrapExpression(node) {
526
+ let current = node;
527
+ while ((current?.type === "TSAsExpression" ||
528
+ current?.type === "TSSatisfiesExpression" ||
529
+ current?.type === "TSNonNullExpression" ||
530
+ current?.type === "ParenthesizedExpression") &&
531
+ current.expression) {
532
+ current = current.expression;
533
+ }
534
+ return current;
535
+ }
536
+ function inspectEndpointFile(source) {
537
+ let ast;
386
538
  try {
387
- const ast = (0, parser_1.parse)(source, {
388
- sourceType: "module",
389
- plugins: ["typescript"],
390
- });
391
- const defaultExport = ast.program.body.find((n) => n.type === "ExportDefaultDeclaration" &&
392
- n.declaration.type === "CallExpression");
393
- if (!defaultExport || defaultExport.type !== "ExportDefaultDeclaration")
394
- return false;
395
- const call = defaultExport.declaration;
396
- if (call.type !== "CallExpression" || call.arguments.length === 0)
397
- return false;
398
- const arg = call.arguments[0];
399
- if (arg.type !== "ObjectExpression")
400
- return false;
401
- const streamProp = arg.properties.find((p) => p.type === "ObjectProperty" &&
402
- ((p.key.type === "Identifier" && p.key.name === "stream") ||
403
- (p.key.type === "StringLiteral" && p.key.value === "stream")));
404
- if (!streamProp || streamProp.type !== "ObjectProperty")
405
- return false;
406
- return (streamProp.value.type === "BooleanLiteral" &&
407
- streamProp.value.value === true);
539
+ ast = (0, parser_1.parse)(source, { sourceType: "module", plugins: ["typescript"] });
408
540
  }
409
541
  catch {
410
- return false;
542
+ // Unparseable with a TS-only plugin set — most likely TSX, which is still
543
+ // a real endpoint file. Assume yes and let the app's own typecheck judge
544
+ // it, rather than dropping a route the bundler will deploy regardless.
545
+ return { isEndpoint: true, hasDefaultExport: true, stream: false };
411
546
  }
547
+ const body = ast.program.body;
548
+ const hasDefaultExport = body.some((n) => n.type === "ExportDefaultDeclaration" ||
549
+ (n.type === "ExportNamedDeclaration" &&
550
+ n.specifiers?.some((spec) => spec.type === "ExportSpecifier" &&
551
+ (spec.exported.type === "Identifier"
552
+ ? spec.exported.name
553
+ : spec.exported.value) === "default")));
554
+ const isEndpoint = hasDefaultExport || body.some(callsCreateEndpoint);
555
+ if (!isEndpoint)
556
+ return NOT_AN_ENDPOINT;
557
+ // Stream detection is best-effort on the literal config: it is the only
558
+ // shape we can read `stream: true` out of statically.
559
+ const defaultExport = body.find((n) => n.type === "ExportDefaultDeclaration");
560
+ const call = defaultExport
561
+ ? unwrapExpression(defaultExport.declaration)
562
+ : undefined;
563
+ const arg = call?.type === "CallExpression"
564
+ ? unwrapExpression(call.arguments?.[0] ?? {})
565
+ : undefined;
566
+ if (arg?.type !== "ObjectExpression") {
567
+ return { isEndpoint, hasDefaultExport, stream: false };
568
+ }
569
+ const streamProp = arg.properties.find((prop) => prop.type === "ObjectProperty" &&
570
+ ((prop.key?.type === "Identifier" && prop.key.name === "stream") ||
571
+ (prop.key?.type === "StringLiteral" && prop.key.value === "stream")));
572
+ const stream = streamProp?.value?.type === "BooleanLiteral" &&
573
+ streamProp.value.value === true;
574
+ return { isEndpoint, hasDefaultExport, stream };
412
575
  }
576
+ /**
577
+ * Reserved words can't be `const` names. The route is the filename either way,
578
+ * so only the generated identifier needs escaping — `default.ts` becomes
579
+ * `export const _default`, still reachable as `api.default` because reserved
580
+ * words are legal object keys.
581
+ */
582
+ const RESERVED_IDENTIFIERS = new Set([
583
+ "await", "break", "case", "catch", "class", "const", "continue", "debugger",
584
+ "default", "delete", "do", "else", "enum", "export", "extends", "false",
585
+ "finally", "for", "function", "if", "implements", "import", "in",
586
+ "instanceof", "interface", "let", "new", "null", "package", "private",
587
+ "protected", "public", "return", "static", "super", "switch", "this",
588
+ "throw", "true", "try", "typeof", "var", "void", "while", "with", "yield",
589
+ ]);
590
+ const toSafeIdentifier = (camelName) => RESERVED_IDENTIFIERS.has(camelName) ? `_${camelName}` : camelName;
413
591
  function generateApiTs(endpointFiles) {
414
592
  if (!endpointFiles || endpointFiles.length === 0)
415
593
  return null;
416
594
  const endpoints = [];
595
+ const seenIdents = new Set();
596
+ const skipped = [];
417
597
  for (const file of endpointFiles) {
418
598
  const fileName = typeof file === "string" ? file : file.fileName;
419
599
  const content = typeof file === "string" ? undefined : file.content;
420
- const name = fileName.replace(/\.(ts|js)$/, "");
421
- const camelName = toCamelCase(name);
422
- const pascal = toPascalCase(camelName);
423
- const stream = content ? detectStreamEnabled(content) : false;
424
- endpoints.push({ camelName, pascal, stream });
600
+ // `foo.d.ts` -> baseName `foo.d`, which imports nothing and camelCases to
601
+ // `fooD`. Declaration files are never endpoints.
602
+ if (fileName.endsWith(".d.ts"))
603
+ continue;
604
+ // Callers that pass bare filenames can't be filtered — every in-tree caller
605
+ // passes content, and so does the backend's migration assembler.
606
+ const shape = content ? inspectEndpointFile(content) : undefined;
607
+ if (shape && !shape.isEndpoint) {
608
+ skipped.push(fileName);
609
+ continue;
610
+ }
611
+ const baseName = fileName.replace(/\.(ts|js)$/, "");
612
+ const key = toCamelCase(baseName);
613
+ const ident = toSafeIdentifier(key);
614
+ // Distinct files can collide on one identifier (`send-email.ts` and
615
+ // `send_email.ts` both camelCase to `sendEmail`), which used to emit the
616
+ // same `export const` twice. First-wins, which is stable because every
617
+ // caller sorts its input before calling — the two in this package and
618
+ // `generateApiClient` in the backend's migration assembler.
619
+ if (seenIdents.has(ident)) {
620
+ skipped.push(`${fileName} (name collides with '${ident}')`);
621
+ continue;
622
+ }
623
+ seenIdents.add(ident);
624
+ endpoints.push({
625
+ baseName,
626
+ key,
627
+ ident,
628
+ pascal: toPascalCase(key),
629
+ stream: shape?.stream ?? false,
630
+ // No content to inspect means a bare-filename caller, which historically
631
+ // assumed a default export — keep that assumption.
632
+ typed: shape ? shape.hasDefaultExport : true,
633
+ });
425
634
  }
635
+ if (endpoints.length === 0)
636
+ return null;
426
637
  const hasStreaming = endpoints.some((e) => e.stream);
427
638
  const lines = [
428
639
  "// Auto-generated by zitejs generate. Do not edit manually.",
@@ -432,30 +643,52 @@ function generateApiTs(endpointFiles) {
432
643
  : "import { createCaller } from 'zitejs/caller';",
433
644
  "",
434
645
  ];
435
- for (const { pascal, camelName } of endpoints) {
436
- const name = camelName.replace(/([A-Z])/g, "-$1").toLowerCase();
437
- const rawName = endpointFiles[endpoints.findIndex((e) => e.camelName === camelName)];
438
- const fileName = typeof rawName === "string" ? rawName : rawName.fileName;
439
- const baseName = fileName.replace(/\.(ts|js)$/, "");
646
+ if (skipped.length > 0) {
647
+ lines.push("// Not endpoints, so no callers were generated for them:");
648
+ for (const name of skipped)
649
+ lines.push(`// ${name}`);
650
+ lines.push("");
651
+ }
652
+ for (const { pascal, baseName, typed } of endpoints) {
653
+ if (!typed)
654
+ continue;
440
655
  lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
441
656
  }
442
657
  lines.push("");
443
- for (const { camelName, pascal, stream } of endpoints) {
658
+ for (const { baseName, ident, pascal, stream, typed } of endpoints) {
659
+ if (!typed) {
660
+ // Declared with `export const x = createEndpoint(...)` — the 1.0 shape.
661
+ // The route is real and the bundler deploys it, so the caller has to
662
+ // exist; there is just no default export to read its types from.
663
+ lines.push(`// '${baseName}' has no default export, so its input/output are untyped.`);
664
+ lines.push(`export type ${pascal}InputType = unknown;`);
665
+ lines.push(`export type ${pascal}OutputType = unknown;`);
666
+ lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
667
+ lines.push("");
668
+ continue;
669
+ }
444
670
  lines.push(`type _${pascal}Cfg = typeof _${pascal}Ep;`);
445
- lines.push(`export type ${pascal}InputType = Parameters<_${pascal}Cfg['execute']>[0]['input'];`);
671
+ // A caller sends the schema's INPUT type, not its output: a field with a
672
+ // `.default()` or a transform is optional to send and guaranteed on the
673
+ // other side. Reading it off `execute` gave the output type, so callers
674
+ // were forced to pass values the schema exists to supply. `NonNullable`
675
+ // because `inputSchema` is optional — without it the conditional always
676
+ // took the fallback branch.
677
+ lines.push(`export type ${pascal}InputType = NonNullable<_${pascal}Cfg['inputSchema']> extends { _input: infer I } ? I : Parameters<_${pascal}Cfg['execute']>[0]['input'];`);
446
678
  lines.push(`export type ${pascal}OutputType = Awaited<ReturnType<_${pascal}Cfg['execute']>>;`);
447
- if (stream) {
448
- lines.push(`export const ${camelName} = createStreamingCaller<${pascal}InputType, ${pascal}OutputType>('${camelName}');`);
449
- }
450
- else {
451
- lines.push(`export const ${camelName} = createCaller<${pascal}InputType, ${pascal}OutputType>('${camelName}');`);
452
- }
679
+ // The route is the filename, not the camelCased identifier: the bundler
680
+ // keys endpoints by filename base and the runner looks up `/api/:name`
681
+ // against those keys, so `send-email.ts` is reachable at `/api/send-email`.
682
+ // Calling it `sendEmail` here made every hyphenated or snake_cased endpoint
683
+ // a 404.
684
+ const caller = stream ? "createStreamingCaller" : "createCaller";
685
+ lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
453
686
  lines.push("");
454
687
  }
455
688
  lines.push("");
456
689
  lines.push("export const api = {");
457
- for (const { camelName } of endpoints) {
458
- lines.push(` ${camelName},`);
690
+ for (const { key, ident } of endpoints) {
691
+ lines.push(key === ident ? ` ${ident},` : ` ${key}: ${ident},`);
459
692
  }
460
693
  lines.push("};");
461
694
  lines.push("");
@@ -485,8 +718,8 @@ const AIRTABLE_FIELD_TYPE_MAP = {
485
718
  dateTime: "string",
486
719
  singleSelect: "string",
487
720
  multipleSelects: "string[]",
488
- multipleAttachments: "Array<{ url: string; filename?: string }>",
489
- multipleRecordLinks: "string | string[]",
721
+ multipleAttachments: "AirtableAttachment[]",
722
+ multipleRecordLinks: "string[]",
490
723
  singleCollaborator: "{ id: string; email: string; name?: string }",
491
724
  multipleCollaborators: "Array<{ id: string; email: string; name?: string }>",
492
725
  // Read-only fields
@@ -503,7 +736,49 @@ const AIRTABLE_FIELD_TYPE_MAP = {
503
736
  externalSyncSource: "unknown",
504
737
  aiText: "string",
505
738
  };
506
- function airtableTsType(field, lock, depth) {
739
+ /**
740
+ * Field types whose WRITE shape is looser than what Airtable returns.
741
+ * Attachments come back as full `AirtableAttachment` objects but are written as
742
+ * `{ url, filename? }`; a link field returns an array but accepts one id.
743
+ */
744
+ const AIRTABLE_FIELD_INPUT_TYPE_MAP = {
745
+ multipleAttachments: "Array<{ url: string; filename?: string }>",
746
+ multipleRecordLinks: "string | string[]",
747
+ singleCollaborator: "{ id: string } | { email: string }",
748
+ multipleCollaborators: "Array<{ id: string } | { email: string }>",
749
+ };
750
+ /** Mirrors `airtable/lib/attachment.d.ts`. Every property but `thumbnails` is present on a read. */
751
+ const AIRTABLE_ATTACHMENT_TYPE = [
752
+ "export type AirtableAttachment = {",
753
+ " id: string;",
754
+ " url: string;",
755
+ " filename: string;",
756
+ " /** Size in bytes. */",
757
+ " size: number;",
758
+ " /** MIME type. */",
759
+ " type: string;",
760
+ " thumbnails?: {",
761
+ " small: { url: string; width: number; height: number };",
762
+ " large: { url: string; width: number; height: number };",
763
+ " full: { url: string; width: number; height: number };",
764
+ " };",
765
+ "};",
766
+ "",
767
+ ];
768
+ function airtableTsType(field, lock, depth, variant = "read") {
769
+ if (variant === "write" && AIRTABLE_FIELD_INPUT_TYPE_MAP[field.type]) {
770
+ return AIRTABLE_FIELD_INPUT_TYPE_MAP[field.type];
771
+ }
772
+ // A formula or rollup's cell type is whatever its expression produces, and
773
+ // Airtable reports that in the field's `result`. Reading it turns `unknown`
774
+ // — which forces a cast at every use — into the actual type.
775
+ if ((field.type === "formula" || field.type === "rollup") &&
776
+ (depth ?? 0) < 5) {
777
+ const result = field.config?.result;
778
+ if (result?.type && result.type !== field.type) {
779
+ return airtableTsType({ ...field, type: result.type, config: undefined }, lock, (depth ?? 0) + 1, variant);
780
+ }
781
+ }
507
782
  const choices = field.config?.choices;
508
783
  if ((field.type === "singleSelect" || field.type === "multipleSelects") &&
509
784
  choices &&
@@ -550,7 +825,7 @@ const READ_ONLY_AIRTABLE_FIELDS = new Set([
550
825
  "externalSyncSource",
551
826
  "aiText",
552
827
  ]);
553
- function airtableFieldJsdoc(field, table) {
828
+ function airtableFieldJsdoc(field, table, lock) {
554
829
  const parts = [];
555
830
  if (table.primaryFieldId === field.id)
556
831
  parts.push("Primary field");
@@ -558,8 +833,10 @@ function airtableFieldJsdoc(field, table) {
558
833
  parts.push("Read-only; do not write");
559
834
  if (field.type === "multipleRecordLinks") {
560
835
  const linkedTableId = field.config?.linkedTableId;
561
- if (linkedTableId)
562
- parts.push(`Links to table ${linkedTableId}`);
836
+ if (linkedTableId) {
837
+ const linked = lock?.tables.find((t) => t.id === linkedTableId);
838
+ parts.push(`Links to ${linked ? linked.sdkName : linkedTableId}`);
839
+ }
563
840
  if (field.config?.prefersSingleRecordLink)
564
841
  parts.push("Single record only");
565
842
  }
@@ -626,32 +903,64 @@ function generateAirtableTs(lock) {
626
903
  "// update({ id, record }) => { id: string, fields: T } | undefined",
627
904
  "// delete({ id }) => { id: string }",
628
905
  "//",
906
+ "// Reading these types:",
907
+ "// Every field is OPTIONAL. Airtable omits an empty cell from the response",
908
+ "// entirely rather than sending null, so any field can be undefined on any row.",
909
+ "// SDK names are stable identifiers and do not change when a field is renamed —",
910
+ '// each carries its current user-facing name in quotes (// "Task Name"), which is',
911
+ "// the source of truth for what it means.",
912
+ "// Values are RAW; format them for display using each field's comment.",
913
+ "// Fields marked \"Links to X\" hold Airtable record ids (rec...) from",
914
+ "// findOne/findAll — never invent one.",
915
+ "// Read-only fields are absent from the *RecordInput types: Airtable rejects a",
916
+ "// write to a computed one (formula, rollup, lookup, autonumber, the created/",
917
+ "// modified metadata, button, aiText) with a 422.",
918
+ "//",
629
919
  "// Usage tips:",
630
920
  "// - Airtable has a strict rate limit of 5 requests/second per base",
631
- "// - Use bulkCreate() instead of calling create() in a loop",
632
- "// - findAll() offsets are opaque strings from previous calls, NOT numbers",
921
+ "// - Use bulkCreate() instead of calling create() in a loop, but chunk it",
922
+ "// yourself: Airtable rejects more than 10 records in one create request",
923
+ "// with a 422, and not every runtime path batches for you",
924
+ "// - findAll() offsets are opaque cursor strings from a previous call, NOT row",
925
+ "// counts (unlike zite.<table>.findAll, whose offset IS a number)",
633
926
  "// - Always destructure record properties individually in create/update calls",
634
927
  "",
635
928
  "import { createAirtableClient } from 'zitejs/runtime';",
636
929
  "",
637
930
  ];
931
+ lines.push(...AIRTABLE_ATTACHMENT_TYPE);
638
932
  for (const table of lock.tables) {
639
933
  const recordType = `${table.sdkName}RecordType`;
934
+ lines.push(`/** A ${table.sdkName} record as it is read back. */`);
640
935
  lines.push(`export type ${recordType} = {`);
641
936
  lines.push(" id: string;");
642
937
  for (const field of table.fields) {
643
938
  if (field.sdkName === "id")
644
939
  continue;
645
- const jsdoc = airtableFieldJsdoc(field, table);
940
+ const jsdoc = airtableFieldJsdoc(field, table, lock);
646
941
  if (jsdoc) {
647
942
  lines.push(` /** ${jsdoc} */`);
648
943
  }
649
944
  const tsType = airtableTsType(field, lock);
650
- lines.push(` ${field.sdkName}: ${tsType};`);
945
+ // Optional, because Airtable omits a field from the response entirely
946
+ // when its cell is empty — it does not send null. Declaring these
947
+ // required told app code every cell was populated, and `record.notes`
948
+ // typed `string` is `undefined` at runtime on the first blank row.
949
+ lines.push(` ${field.sdkName}?: ${tsType};`);
950
+ }
951
+ lines.push("};");
952
+ lines.push("");
953
+ // Read-only fields are omitted rather than typed: Airtable rejects a write
954
+ // to a formula, rollup, lookup or autonumber with a 422.
955
+ const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
956
+ lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
957
+ lines.push(`export type ${table.sdkName}RecordInput = {`);
958
+ for (const field of writableFields) {
959
+ lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
651
960
  }
652
961
  lines.push("};");
653
962
  lines.push("");
654
- lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}>(`);
963
+ lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}, ${table.sdkName}RecordInput>(`);
655
964
  lines.push(` '${lock.integrationId}',`);
656
965
  lines.push(` '${table.sdkName}',`);
657
966
  lines.push(` { tableId: '${table.id}' },`);
@@ -669,45 +978,71 @@ function generateBackendWrapperTs(envVarNames = []) {
669
978
  "// Auto-generated type-narrowing wrapper. Do not edit manually.",
670
979
  "// Re-exports createEndpoint with context.user typed to the app User.",
671
980
  "",
672
- "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext as _ZiteScheduledContext, ZiteSchedule, ZiteWebhook } from 'zitejs/backend/base';",
981
+ "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteWebhook } from 'zitejs/backend/base';",
673
982
  "import type { User } from 'zitejs/auth';",
674
983
  "",
675
- "export type { ZiteSchedule, ZiteWebhook };",
984
+ "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteWebhook };",
985
+ // The pre-monorepo SDK put this in scope for every endpoint, so migrated
986
+ // code can name it. `createEndpoint` infers the same thing without it.
987
+ "export type InferSchemaType<T> = T extends { _output: infer U } ? U : T;",
676
988
  "",
677
989
  "export class ZiteError extends Error {",
678
- " statusCode: number;",
679
- " constructor(message: string, options?: { statusCode?: number }) {",
680
- " super(message);",
990
+ " code: ZiteErrorCode;",
991
+ " /** Short, non-technical message suitable for showing to an end user. */",
992
+ " userFacingMessage?: string;",
993
+ " // Both shapes, mirroring the worker's own class: object form preferred,",
994
+ " // positional form is what pre-monorepo app code was written against.",
995
+ " constructor(options: { code: ZiteErrorCode; message: string; userFacingMessage?: string });",
996
+ " constructor(message: string, code?: ZiteErrorCode);",
997
+ " constructor(",
998
+ " optionsOrMessage: { code: ZiteErrorCode; message: string; userFacingMessage?: string } | string,",
999
+ " legacyCode?: ZiteErrorCode,",
1000
+ " ) {",
1001
+ " if (typeof optionsOrMessage === 'string') {",
1002
+ " super(optionsOrMessage);",
1003
+ " this.code = legacyCode ?? 'INTERNAL_ERROR';",
1004
+ " } else {",
1005
+ " super(optionsOrMessage.message);",
1006
+ " this.code = optionsOrMessage.code;",
1007
+ " this.userFacingMessage = optionsOrMessage.userFacingMessage;",
1008
+ " }",
681
1009
  " this.name = 'ZiteError';",
682
- " this.statusCode = options?.statusCode ?? 500;",
683
1010
  " }",
684
1011
  "}",
685
1012
  "",
1013
+ // Narrows `user` to this app's generated User. ZiteScheduledContext is
1014
+ // re-exported unchanged — a scheduled fire has no user at all, so there is
1015
+ // nothing to narrow.
686
1016
  'export interface ZiteRequestContext extends Omit<_ZiteRequestContext, "user"> {',
687
1017
  " user: User;",
688
1018
  "}",
689
1019
  "",
690
- 'export interface ZiteScheduledContext extends Omit<_ZiteScheduledContext, "user"> {',
691
- " user: User;",
692
- " scheduledAt: string;",
693
- "}",
694
- "",
695
- "type SchemaLike<T> = { _output: T; parse: (data: unknown) => T };",
1020
+ "type SchemaLike<TOut, TIn = TOut> = { _output: TOut; _input: TIn; parse: (data: unknown) => TOut };",
696
1021
  "",
697
- "export interface EndpointConfig<TInput = unknown, TOutput = unknown> {",
1022
+ // TStream mirrors zitejs/backend/base. Without it `stream: true` endpoints
1023
+ // get no `stream` argument here — and this wrapper, not the base module, is
1024
+ // what `zitejs/backend` resolves to in every app.
1025
+ "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
698
1026
  " description?: string;",
699
- " inputSchema?: SchemaLike<TInput>;",
1027
+ " inputSchema?: SchemaLike<TInput, TRawInput>;",
700
1028
  " outputSchema?: SchemaLike<TOutput>;",
701
- " stream?: boolean;",
1029
+ " stream?: TStream;",
702
1030
  " authenticated?: boolean;",
703
- " schedule?: ZiteSchedule;",
704
- " webhook?: ZiteWebhook;",
705
- " execute: (params: { input: TInput; context: ZiteRequestContext | ZiteScheduledContext }) => Promise<TOutput> | TOutput;",
1031
+ " /** 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. */",
1032
+ " schedule?: TSchedule;",
1033
+ " /** When set, an inbound webhook can also trigger this endpoint. Like `schedule`, it widens `context` — a webhook fire has no session. */",
1034
+ " webhook?: TWebhook;",
1035
+ " execute: (",
1036
+ " params: {",
1037
+ " input: TInput;",
1038
+ " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1039
+ " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}),",
1040
+ " ) => Promise<TOutput> | TOutput;",
706
1041
  "}",
707
1042
  "",
708
- "export function createEndpoint<TInput = unknown, TOutput = unknown>(",
709
- " config: EndpointConfig<TInput, TOutput>,",
710
- "): EndpointConfig<TInput, TOutput> {",
1043
+ "export function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(",
1044
+ " config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>,",
1045
+ "): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput> {",
711
1046
  " return config;",
712
1047
  "}",
713
1048
  "",