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.
@@ -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: "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",
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
- return "string";
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
- parts.push(`Links to table ${tpl.tableId}`);
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: string | null;",
254
- " lastName: string | null;",
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: { contains|gt|lt|gte|lte: value } } for operators");
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 detectStreamEnabled(source) {
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
- if (!streamProp || streamProp.type !== "ObjectProperty")
405
- return false;
406
- return (streamProp.value.type === "BooleanLiteral" &&
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
- return false;
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
- 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 });
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
- 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)$/, "");
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 { camelName, pascal, stream } of endpoints) {
595
+ for (const { baseName, ident, pascal, stream } of endpoints) {
444
596
  lines.push(`type _${pascal}Cfg = typeof _${pascal}Ep;`);
445
- lines.push(`export type ${pascal}InputType = Parameters<_${pascal}Cfg['execute']>[0]['input'];`);
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
- 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
- }
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 { camelName } of endpoints) {
458
- lines.push(` ${camelName},`);
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: "Array<{ url: string; filename?: string }>",
489
- multipleRecordLinks: "string | 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
- function airtableTsType(field, lock, depth) {
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
- parts.push(`Links to table ${linkedTableId}`);
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
- "// - findAll() offsets are opaque strings from previous calls, NOT numbers",
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
- lines.push(` ${field.sdkName}: ${tsType};`);
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
- lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}>(`);
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 as _ZiteScheduledContext, ZiteSchedule, ZiteWebhook } from 'zitejs/backend/base';",
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
- " statusCode: number;",
679
- " constructor(message: string, options?: { statusCode?: number }) {",
680
- " super(message);",
911
+ " code: ZiteErrorCode;",
912
+ " constructor(options: { code: ZiteErrorCode; message: string }) {",
913
+ " super(options.message);",
681
914
  " this.name = 'ZiteError';",
682
- " this.statusCode = options?.statusCode ?? 500;",
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
- '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 };",
926
+ "type SchemaLike<TOut, TIn = TOut> = { _output: TOut; _input: TIn; parse: (data: unknown) => TOut };",
696
927
  "",
697
- "export interface EndpointConfig<TInput = unknown, TOutput = unknown> {",
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?: boolean;",
935
+ " stream?: TStream;",
702
936
  " authenticated?: boolean;",
703
- " schedule?: ZiteSchedule;",
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: (params: { input: TInput; context: ZiteRequestContext | ZiteScheduledContext }) => Promise<TOutput> | TOutput;",
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,2 @@
1
+ export { createCaller } from '../caller/index.js';
2
+ export type { EndpointConfig } from '../caller/index.js';
@@ -0,0 +1 @@
1
+ export { createCaller } from '../caller/index.js';