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