zitejs 0.9.110 → 0.9.112

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.
@@ -61,7 +61,13 @@ export type ZiteStreamInterface = {
61
61
  export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {
62
62
  description?: string;
63
63
  inputSchema?: SchemaLike<TInput, TRawInput>;
64
- outputSchema?: SchemaLike<TOutput>;
64
+ /**
65
+ * Documentation only — never validated, and `unknown` so it cannot compete
66
+ * with `execute` for `TOutput`. A nullable column reads as `T | undefined`
67
+ * while its schema says `.nullable()`; making both inference sites failed the
68
+ * build on the difference.
69
+ */
70
+ outputSchema?: SchemaLike<unknown>;
65
71
  stream?: TStream;
66
72
  authenticated?: boolean;
67
73
  /**
@@ -1,5 +1,6 @@
1
1
  import type { PublicFieldDefinition } from "../types/fields.js";
2
2
  import type { Database } from "../types/tables.js";
3
+ import { type SdkNameRename } from "./sdkNames.js";
3
4
  export type ZiteSchemaField = {
4
5
  id: string;
5
6
  sdkName: string;
@@ -8,6 +9,7 @@ export type ZiteSchemaField = {
8
9
  export type ZiteSchemaTable = {
9
10
  id: string;
10
11
  sdkName: string;
12
+ name?: string;
11
13
  primaryFieldId?: string;
12
14
  fields: ZiteSchemaField[];
13
15
  };
@@ -44,6 +46,7 @@ export type AirtableLockField = {
44
46
  export type AirtableLockTable = {
45
47
  id: string;
46
48
  sdkName: string;
49
+ name?: string;
47
50
  primaryFieldId: string;
48
51
  fields: AirtableLockField[];
49
52
  };
@@ -51,7 +54,33 @@ export type AirtableLock = {
51
54
  integrationId: string;
52
55
  tables: AirtableLockTable[];
53
56
  };
54
- export declare function generateAirtableTs(lock: AirtableLock): string | null;
57
+ /**
58
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
59
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
60
+ * exclude.
61
+ *
62
+ * A contested name goes to NOBODY. The lock cannot say which table app code
63
+ * meant, and lock order is no oracle: a rename leaves the original owner
64
+ * first, while reordering or multi-base flattening can put the newcomer
65
+ * first. Guessing wrong would keep stale references compiling against the
66
+ * other table's data (the client is addressed by `tableId`, so the binding
67
+ * follows whichever table holds the name). With the bare name gone, a stale
68
+ * reference fails as an unresolved import instead: visible and fixable.
69
+ *
70
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
71
+ * server from stored nameMappings that a generate-time rename can never
72
+ * update, so a renamed field would type-check yet read `undefined` and fail
73
+ * every write. Unlike tables, duplicates cannot form there: the producer
74
+ * seeds field allocation from the stored mappings.
75
+ *
76
+ * Names are assigned in declaration order, so an unchanged lock regenerates
77
+ * the identical file.
78
+ */
79
+ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
80
+ lock: AirtableLock;
81
+ renames: SdkNameRename[];
82
+ };
83
+ export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
55
84
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
56
85
  /**
57
86
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
@@ -4,6 +4,7 @@ exports.toSdkName = exports.toCamelCase = exports.toPascalCase = void 0;
4
4
  exports.generateSchema = generateSchema;
5
5
  exports.generateDbTs = generateDbTs;
6
6
  exports.generateApiTs = generateApiTs;
7
+ exports.normalizeAirtableLockNames = normalizeAirtableLockNames;
7
8
  exports.generateAirtableTs = generateAirtableTs;
8
9
  exports.generateBackendWrapperTs = generateBackendWrapperTs;
9
10
  exports.generateEmailSdk = generateEmailSdk;
@@ -267,9 +268,12 @@ function fieldJsdoc(schemaField, table, schema) {
267
268
  const tpl = def.template;
268
269
  if (tpl.tableId) {
269
270
  // The SDK name, not the raw `tbl...` id — the id appears nowhere the
270
- // reader can act on, while the SDK name is the property on `zite`.
271
+ // reader can act on, while the SDK name is the property on `zite`. Its
272
+ // display name rides along, since after a rename the two differ.
271
273
  const linked = schema?.tables.find((t) => t.id === tpl.tableId);
272
- parts.push(`Links to ${linked ? linked.sdkName : tpl.tableId}`);
274
+ parts.push(linked
275
+ ? `Links to ${linked.sdkName}${linked.name ? ` ("${linked.name}")` : ""}`
276
+ : `Links to ${tpl.tableId}`);
273
277
  }
274
278
  if (tpl.allowMultiple === false)
275
279
  parts.push("Single record only");
@@ -382,6 +386,10 @@ function generateSchema(database, existingSchema) {
382
386
  table: {
383
387
  id: table.id,
384
388
  sdkName: "",
389
+ // Always the live name — never `existingTable?.name`. Locking it the way
390
+ // `sdkName` is locked would freeze it at the first name the table ever
391
+ // had, which is the one thing this is here to avoid.
392
+ name: table.name,
385
393
  primaryFieldId: table.primaryFieldId,
386
394
  fields: [],
387
395
  },
@@ -440,6 +448,13 @@ function buildLinkTableComments(schema) {
440
448
  const tablesById = new Map();
441
449
  for (const t of schema.tables)
442
450
  tablesById.set(t.id, t);
451
+ // The link name itself is a literal SQL identifier ("use these exact names"),
452
+ // so it stays sdkName-derived; the display names ride alongside as a gloss.
453
+ const displayByPascal = new Map();
454
+ for (const t of schema.tables) {
455
+ if (t.name)
456
+ displayByPascal.set((0, sdkNames_js_1.toPascalCase)(t.sdkName), t.name);
457
+ }
443
458
  const seen = new Set();
444
459
  const entries = [];
445
460
  for (const table of schema.tables) {
@@ -469,7 +484,15 @@ function buildLinkTableComments(schema) {
469
484
  const col2 = isSelf
470
485
  ? `target${second}Id`
471
486
  : `${second.charAt(0).toLowerCase()}${second.slice(1)}Id`;
472
- entries.push({ name: linkName, cols: [col1, col2] });
487
+ const firstName = displayByPascal.get(first);
488
+ const secondName = displayByPascal.get(second);
489
+ entries.push({
490
+ name: linkName,
491
+ cols: [col1, col2],
492
+ gloss: firstName && secondName
493
+ ? `joins "${firstName}" and "${secondName}"`
494
+ : undefined,
495
+ });
473
496
  }
474
497
  }
475
498
  if (entries.length === 0)
@@ -479,7 +502,8 @@ function buildLinkTableComments(schema) {
479
502
  "// Link tables for zite.sql() JOINs (use these exact names):",
480
503
  ];
481
504
  for (const e of entries) {
482
- lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
505
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"` +
506
+ (e.gloss ? ` (${commentSafe(e.gloss)})` : ""));
483
507
  }
484
508
  return lines;
485
509
  }
@@ -566,7 +590,12 @@ function generateDbTs(inputSchema) {
566
590
  for (const table of tables) {
567
591
  const className = (0, sdkNames_js_1.toPascalCase)(table.sdkName);
568
592
  const recordType = `${className}RecordType`;
569
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
593
+ // A schema written before `name` existed keeps the old wording exactly —
594
+ // sandbox boot regenerates unconditionally, so any drift here would show up
595
+ // as an unexplained diff in every project's next commit.
596
+ lines.push(table.name
597
+ ? `/** A record in the "${commentSafe(table.name)}" table, as it is read back. */`
598
+ : `/** A ${table.sdkName} record as it is read back. */`);
570
599
  lines.push(`export type ${recordType} = {`);
571
600
  lines.push(" id: string;");
572
601
  for (const field of table.fields) {
@@ -590,7 +619,9 @@ function generateDbTs(inputSchema) {
590
619
  // input than they store (an attachments field takes a URL string; a linked
591
620
  // record takes one id or many).
592
621
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_FIELD_TYPES.has(f.definition.type));
593
- lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
622
+ lines.push(table.name
623
+ ? `/** What you may write when creating or updating a record in the "${commentSafe(table.name)}" table. */`
624
+ : `/** What you may write when creating or updating a ${table.sdkName}. */`);
594
625
  lines.push(`export type ${className}RecordInput = {`);
595
626
  for (const field of writableFields) {
596
627
  lines.push(` ${field.sdkName}: ${tsTypeForSchemaField(field.definition, "write")};`);
@@ -602,7 +633,8 @@ function generateDbTs(inputSchema) {
602
633
  lines.push("export const zite = {");
603
634
  for (const table of tables) {
604
635
  const className = (0, sdkNames_js_1.toPascalCase)(table.sdkName);
605
- lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
636
+ lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),` +
637
+ (table.name ? ` // "${commentSafe(table.name)}"` : ""));
606
638
  }
607
639
  lines.push(` sql: createSqlClient(),`);
608
640
  // No `notifications` here: it is a platform primitive, not something backed
@@ -1001,7 +1033,9 @@ function airtableFieldJsdoc(field, table, lock) {
1001
1033
  const linkedTableId = field.config?.linkedTableId;
1002
1034
  if (linkedTableId) {
1003
1035
  const linked = lock?.tables.find((t) => t.id === linkedTableId);
1004
- parts.push(`Links to ${linked ? linked.sdkName : linkedTableId}`);
1036
+ parts.push(linked
1037
+ ? `Links to ${linked.sdkName}${linked.name ? ` ("${linked.name}")` : ""}`
1038
+ : `Links to ${linkedTableId}`);
1005
1039
  }
1006
1040
  if (field.config?.prefersSingleRecordLink)
1007
1041
  parts.push("Single record only");
@@ -1054,9 +1088,71 @@ function airtableFieldJsdoc(field, table, lock) {
1054
1088
  return undefined;
1055
1089
  return parts.join(". ");
1056
1090
  }
1057
- function generateAirtableTs(lock) {
1058
- if (lock.tables.length === 0)
1091
+ // A table client is a `const`, so only value-space bindings can collide with
1092
+ // one. `AirtableAttachment` is a type, and types and consts coexist.
1093
+ const RESERVED_AIRTABLE_NAMES = ["createAirtableClient"];
1094
+ /**
1095
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
1096
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
1097
+ * exclude.
1098
+ *
1099
+ * A contested name goes to NOBODY. The lock cannot say which table app code
1100
+ * meant, and lock order is no oracle: a rename leaves the original owner
1101
+ * first, while reordering or multi-base flattening can put the newcomer
1102
+ * first. Guessing wrong would keep stale references compiling against the
1103
+ * other table's data (the client is addressed by `tableId`, so the binding
1104
+ * follows whichever table holds the name). With the bare name gone, a stale
1105
+ * reference fails as an unresolved import instead: visible and fixable.
1106
+ *
1107
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
1108
+ * server from stored nameMappings that a generate-time rename can never
1109
+ * update, so a renamed field would type-check yet read `undefined` and fail
1110
+ * every write. Unlike tables, duplicates cannot form there: the producer
1111
+ * seeds field allocation from the stored mappings.
1112
+ *
1113
+ * Names are assigned in declaration order, so an unchanged lock regenerates
1114
+ * the identical file.
1115
+ */
1116
+ function normalizeAirtableLockNames(lock) {
1117
+ const owners = new Map();
1118
+ for (const table of lock.tables) {
1119
+ owners.set(table.sdkName, (owners.get(table.sdkName) ?? 0) + 1);
1120
+ }
1121
+ const contested = [...owners.entries()]
1122
+ .filter(([, count]) => count > 1)
1123
+ .map(([name]) => name);
1124
+ const tableNames = new sdkNames_js_1.SdkNameAllocator({
1125
+ reserved: [...RESERVED_AIRTABLE_NAMES, ...contested],
1126
+ });
1127
+ // Sole owners claim their names before any contender is suffixed, so a
1128
+ // contender can never displace a table legitimately named `GroupCalls2`.
1129
+ const kept = lock.tables.map((table) => owners.get(table.sdkName) === 1 && tableNames.available(table.sdkName)
1130
+ ? tableNames.allocate(table.sdkName)
1131
+ : undefined);
1132
+ const renames = [];
1133
+ const tables = lock.tables.map((table, i) => {
1134
+ const sdkName = kept[i] ?? tableNames.allocate(table.sdkName);
1135
+ if (sdkName !== table.sdkName) {
1136
+ renames.push({
1137
+ kind: "table",
1138
+ tableId: table.id,
1139
+ from: table.sdkName,
1140
+ to: sdkName,
1141
+ });
1142
+ }
1143
+ return { ...table, sdkName };
1144
+ });
1145
+ return { lock: { ...lock, tables }, renames };
1146
+ }
1147
+ function generateAirtableTs(inputLock) {
1148
+ if (inputLock.tables.length === 0)
1059
1149
  return null;
1150
+ const { lock, renames } = normalizeAirtableLockNames(inputLock);
1151
+ if (renames.length > 0) {
1152
+ for (const line of (0, sdkNames_js_1.describeRenames)(renames)) {
1153
+ console.warn(`[zitejs] SDK name conflict in zite.lock; the generated Airtable SDK uses: ${line}`);
1154
+ }
1155
+ }
1060
1156
  const lines = [
1061
1157
  "// Auto-generated by zitejs generate from zite.lock. Do not edit manually.",
1062
1158
  "// Airtable SDK — uses createAirtableClient from zitejs/runtime.",
@@ -1097,7 +1193,9 @@ function generateAirtableTs(lock) {
1097
1193
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1098
1194
  for (const table of lock.tables) {
1099
1195
  const recordType = `${table.sdkName}RecordType`;
1100
- lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1196
+ lines.push(table.name
1197
+ ? `/** A record in the "${commentSafe(table.name)}" table, as it is read back. */`
1198
+ : `/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1101
1199
  lines.push(`export type ${recordType} = {`);
1102
1200
  lines.push(" id: string;");
1103
1201
  for (const field of table.fields) {
@@ -1119,7 +1217,9 @@ function generateAirtableTs(lock) {
1119
1217
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1120
1218
  // to a formula, rollup, lookup or autonumber with a 422.
1121
1219
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
1122
- lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1220
+ lines.push(table.name
1221
+ ? `/** What you may write when creating or updating a record in the "${commentSafe(table.name)}" table. */`
1222
+ : `/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1123
1223
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1124
1224
  for (const field of writableFields) {
1125
1225
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1129,7 +1229,8 @@ function generateAirtableTs(lock) {
1129
1229
  lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}, ${table.sdkName}RecordInput>(`);
1130
1230
  lines.push(` '${lock.integrationId}',`);
1131
1231
  lines.push(` '${table.sdkName}',`);
1132
- lines.push(` { tableId: '${table.id}' },`);
1232
+ lines.push(` { tableId: '${table.id}' },` +
1233
+ (table.name ? ` // "${commentSafe(table.name)}"` : ""));
1133
1234
  lines.push(`);`);
1134
1235
  lines.push("");
1135
1236
  }
@@ -1225,7 +1326,9 @@ function generateBackendWrapperTs(envVarNames = []) {
1225
1326
  "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
1226
1327
  " description?: string;",
1227
1328
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
1228
- " outputSchema?: SchemaLike<TOutput>;",
1329
+ // Apps compile against this copy, not the base module's.
1330
+ " /** Documentation only. Never validated, and not an inference site for TOutput. */",
1331
+ " outputSchema?: SchemaLike<unknown>;",
1229
1332
  " stream?: TStream;",
1230
1333
  " authenticated?: boolean;",
1231
1334
  " /** 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. */",
@@ -51,6 +51,173 @@ const schemaWithSelectOption = (label) => ({
51
51
  (0, vitest_1.expect)(out).toContain('"The \\"Premium\\" tier"');
52
52
  });
53
53
  });
54
+ /** TS2300/TS2451, which `syntaxErrorsIn` can't see — a duplicate export parses fine. */
55
+ const duplicateIdentifierErrorsIn = (source) => {
56
+ const AMBIENT = `declare module 'zitejs/runtime' {
57
+ export function createAirtableClient<T, TInput = T>(
58
+ integrationId: string,
59
+ className: string,
60
+ implicitParams: Record<string, unknown>,
61
+ ): unknown;
62
+ }`;
63
+ const sources = {
64
+ 'ambient.d.ts': AMBIENT,
65
+ 'airtable.ts': source,
66
+ };
67
+ const host = typescript_1.default.createCompilerHost({});
68
+ const original = host.getSourceFile.bind(host);
69
+ host.getSourceFile = (fileName, langVersion, onError, shouldCreate) => sources[fileName] !== undefined
70
+ ? typescript_1.default.createSourceFile(fileName, sources[fileName], langVersion, true)
71
+ : original(fileName, langVersion, onError, shouldCreate);
72
+ host.fileExists = f => sources[f] !== undefined || typescript_1.default.sys.fileExists(f);
73
+ host.readFile = f => sources[f] ?? typescript_1.default.sys.readFile(f);
74
+ const program = typescript_1.default.createProgram(Object.keys(sources), { strict: true, noEmit: true, skipLibCheck: true }, host);
75
+ return typescript_1.default
76
+ .getPreEmitDiagnostics(program)
77
+ .filter(d => d.code === 2300 || d.code === 2451)
78
+ .map(d => typescript_1.default.flattenDiagnosticMessageText(d.messageText, ' '));
79
+ };
80
+ (0, vitest_1.describe)('generateAirtableTs duplicate SDK names', () => {
81
+ // ZIT-5032: two tables both resolved to `GroupCalls`.
82
+ const collidingLock = (name) => ({
83
+ integrationId: 'airtable',
84
+ tables: [
85
+ {
86
+ id: 'tblV204b5ogVasPKc',
87
+ sdkName: name,
88
+ primaryFieldId: 'fld1',
89
+ fields: [
90
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
91
+ ],
92
+ },
93
+ {
94
+ id: 'tblt474V0WyNlrHX9',
95
+ sdkName: name,
96
+ primaryFieldId: 'fld2',
97
+ fields: [
98
+ { id: 'fld2', sdkName: 'title', type: 'singleLineText', name: 'Title' },
99
+ ],
100
+ },
101
+ ],
102
+ });
103
+ (0, vitest_1.it)('compiles when two tables resolve to the same SDK name', () => {
104
+ (0, vitest_1.expect)(duplicateIdentifierErrorsIn((0, lib_js_1.generateAirtableTs)(collidingLock('GroupCalls')) ?? '')).toEqual([]);
105
+ });
106
+ // Lock order cannot say which table originally owned the name, and handing
107
+ // it to the wrong one would leave stale references compiling against the
108
+ // other table's data. Suffixing every contender turns them into unresolved
109
+ // imports instead.
110
+ (0, vitest_1.it)('gives a contested name to neither table', () => {
111
+ const out = (0, lib_js_1.generateAirtableTs)(collidingLock('GroupCalls')) ?? '';
112
+ (0, vitest_1.expect)(out).not.toContain('export const GroupCalls = ');
113
+ (0, vitest_1.expect)(out).toContain('export const GroupCalls2 = ');
114
+ (0, vitest_1.expect)(out).toContain('export const GroupCalls3 = ');
115
+ });
116
+ (0, vitest_1.it)('still addresses each table by its own id, so data access is unchanged', () => {
117
+ const out = (0, lib_js_1.generateAirtableTs)(collidingLock('GroupCalls')) ?? '';
118
+ (0, vitest_1.expect)(out).toContain("'GroupCalls2',\n { tableId: 'tblV204b5ogVasPKc' }");
119
+ (0, vitest_1.expect)(out).toContain("'GroupCalls3',\n { tableId: 'tblt474V0WyNlrHX9' }");
120
+ });
121
+ (0, vitest_1.it)('never displaces the legitimate owner of a suffixed name', () => {
122
+ const table = (id, sdkName) => ({
123
+ id,
124
+ sdkName,
125
+ primaryFieldId: 'fld1',
126
+ fields: [
127
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
128
+ ],
129
+ });
130
+ const out = (0, lib_js_1.generateAirtableTs)({
131
+ integrationId: 'airtable',
132
+ tables: [
133
+ table('tblA', 'GroupCalls'),
134
+ table('tblB', 'GroupCalls'),
135
+ table('tblC', 'GroupCalls2'),
136
+ ],
137
+ }) ?? '';
138
+ (0, vitest_1.expect)(out).toContain("'GroupCalls2',\n { tableId: 'tblC' }");
139
+ (0, vitest_1.expect)(out).toContain('export const GroupCalls3 = ');
140
+ (0, vitest_1.expect)(out).toContain('export const GroupCalls4 = ');
141
+ (0, vitest_1.expect)(out).not.toContain('export const GroupCalls = ');
142
+ (0, vitest_1.expect)(duplicateIdentifierErrorsIn(out)).toEqual([]);
143
+ });
144
+ (0, vitest_1.it)('does not rename anything when names are already unique', () => {
145
+ const out = (0, lib_js_1.generateAirtableTs)({
146
+ integrationId: 'airtable',
147
+ tables: [
148
+ {
149
+ id: 'tbl1',
150
+ sdkName: 'Calls',
151
+ primaryFieldId: 'fld1',
152
+ fields: [
153
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
154
+ ],
155
+ },
156
+ ],
157
+ }) ?? '';
158
+ (0, vitest_1.expect)(out).toContain('export const Calls = ');
159
+ (0, vitest_1.expect)(out).not.toContain('Calls2');
160
+ });
161
+ // A field's sdkName is a wire key: the server resolves it from the stored
162
+ // nameMappings, which a generate-time rename can never update. A renamed
163
+ // field would type-check yet read `undefined` and fail every write, so
164
+ // fields are never renamed here.
165
+ (0, vitest_1.it)('leaves field names alone, even when they collide', () => {
166
+ const out = (0, lib_js_1.generateAirtableTs)({
167
+ integrationId: 'airtable',
168
+ tables: [
169
+ {
170
+ id: 'tbl1',
171
+ sdkName: 'Calls',
172
+ primaryFieldId: 'fldA',
173
+ fields: [
174
+ { id: 'fldA', sdkName: 'notes', type: 'singleLineText', name: 'Notes' },
175
+ { id: 'fldB', sdkName: 'notes', type: 'singleLineText', name: 'Notes copy' },
176
+ ],
177
+ },
178
+ ],
179
+ }) ?? '';
180
+ (0, vitest_1.expect)(out).not.toContain('notes2');
181
+ });
182
+ (0, vitest_1.it)('still skips a field named `id` instead of renaming it', () => {
183
+ const out = (0, lib_js_1.generateAirtableTs)({
184
+ integrationId: 'airtable',
185
+ tables: [
186
+ {
187
+ id: 'tbl1',
188
+ sdkName: 'Calls',
189
+ primaryFieldId: 'fldA',
190
+ fields: [
191
+ { id: 'fldA', sdkName: 'id', type: 'singleLineText', name: 'ID' },
192
+ { id: 'fldB', sdkName: 'title', type: 'singleLineText', name: 'Title' },
193
+ ],
194
+ },
195
+ ],
196
+ }) ?? '';
197
+ (0, vitest_1.expect)(out).not.toContain('id2');
198
+ (0, vitest_1.expect)(out).not.toContain('id?:');
199
+ // the record id, emitted by hand, stays the only `id`
200
+ (0, vitest_1.expect)(out).toContain('id: string;');
201
+ });
202
+ (0, vitest_1.it)('leaves a table named after the attachment type alone', () => {
203
+ const out = (0, lib_js_1.generateAirtableTs)({
204
+ integrationId: 'airtable',
205
+ tables: [
206
+ {
207
+ id: 'tbl1',
208
+ sdkName: 'AirtableAttachment',
209
+ primaryFieldId: 'fld1',
210
+ fields: [
211
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
212
+ ],
213
+ },
214
+ ],
215
+ }) ?? '';
216
+ (0, vitest_1.expect)(duplicateIdentifierErrorsIn(out)).toEqual([]);
217
+ (0, vitest_1.expect)(out).toContain('export const AirtableAttachment = ');
218
+ (0, vitest_1.expect)(out).not.toContain('AirtableAttachment2');
219
+ });
220
+ });
54
221
  (0, vitest_1.describe)('generateAirtableTs attachment type', () => {
55
222
  const lockWithAttachment = {
56
223
  integrationId: 'airtable',
@@ -115,6 +282,56 @@ const schemaWithSelectOption = (label) => ({
115
282
  (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateDbTs)(schemaWithFieldNamed(name)))).toEqual([]);
116
283
  });
117
284
  });
285
+ (0, vitest_1.describe)('generateDbTs table names in comments', () => {
286
+ const schemaWithTableNamed = (name) => ({
287
+ tables: [
288
+ {
289
+ id: 'tbl1',
290
+ sdkName: 'table1',
291
+ ...(name === undefined ? {} : { name }),
292
+ primaryFieldId: 'fld1',
293
+ fields: [
294
+ {
295
+ id: 'fld1',
296
+ sdkName: 'slot',
297
+ definition: {
298
+ type: 'single_line_text',
299
+ name: 'Slot',
300
+ template: {},
301
+ },
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ });
307
+ // The whole point: `sdkName` is locked to the table's id, so after a rename
308
+ // `table1` is the only thing a reader sees. The display name is what says
309
+ // which table that is.
310
+ (0, vitest_1.it)('names the table beside its locked sdkName', () => {
311
+ const out = (0, lib_js_1.generateDbTs)(schemaWithTableNamed('Time Slots'));
312
+ (0, vitest_1.expect)(out).toContain('/** A record in the "Time Slots" table, as it is read back. */');
313
+ (0, vitest_1.expect)(out).toContain('/** What you may write when creating or updating a record in the "Time Slots" table. */');
314
+ (0, vitest_1.expect)(out).toContain(`table1: createTableClient<Table1RecordType, Table1RecordInput>('Table1'), // "Time Slots"`);
315
+ });
316
+ // Sandbox boot regenerates unconditionally against the committed schema, so a
317
+ // schema written before `name` existed has to keep producing what it did
318
+ // before — otherwise every project's next commit carries an unexplained diff.
319
+ (0, vitest_1.it)('emits the pre-name wording when the schema carries no name', () => {
320
+ const out = (0, lib_js_1.generateDbTs)(schemaWithTableNamed(undefined));
321
+ (0, vitest_1.expect)(out).toContain('/** A table1 record as it is read back. */');
322
+ (0, vitest_1.expect)(out).toContain('/** What you may write when creating or updating a table1. */');
323
+ (0, vitest_1.expect)(out).toContain(`('Table1'),\n`);
324
+ (0, vitest_1.expect)(out).not.toContain('" table, as it is read back');
325
+ });
326
+ // Same hazard as a field name, one level up: a table's display name is user
327
+ // text and now reaches a JSDoc.
328
+ vitest_1.it.each([
329
+ ['a comment-close sequence', 'Slots */ console.log(1); /*'],
330
+ ['a newline', 'Time\nSlots'],
331
+ ])('emits parseable TypeScript for a table named with %s', (_what, name) => {
332
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateDbTs)(schemaWithTableNamed(name)))).toEqual([]);
333
+ });
334
+ });
118
335
  (0, vitest_1.describe)('generateApiTs endpoint file names', () => {
119
336
  // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
120
337
  // validates its characters — and it lands in an import specifier, a comment
@@ -66,6 +66,33 @@ export interface SdkNameRename {
66
66
  from: string;
67
67
  to: string;
68
68
  }
69
+ /**
70
+ * Unique identifiers within one namespace.
71
+ *
72
+ * `derive` names the extra keys an allocation also occupies. A table needs it:
73
+ * its sdkName becomes both a property on `zite` and, PascalCased, a pair of
74
+ * exported type declarations, so `orders` and `Orders` are distinct accessors
75
+ * that would emit `OrdersRecordType` twice.
76
+ */
77
+ export declare class SdkNameAllocator {
78
+ private readonly taken;
79
+ private readonly reserved;
80
+ private readonly derive;
81
+ constructor(opts?: {
82
+ reserved?: readonly string[];
83
+ derive?: (name: string) => string[];
84
+ });
85
+ private keysFor;
86
+ private isFree;
87
+ /** Free right now, without claiming it. */
88
+ available(name: string): boolean;
89
+ private claim;
90
+ /**
91
+ * `name`, or the first `name2`, `name3`… that is free. Suffixes start at 2
92
+ * because that is what the name means: the second table called Notifications.
93
+ */
94
+ allocate(preferred: string): string;
95
+ }
69
96
  /**
70
97
  * Existing sdkNames are preserved across syncs so user code keeps compiling,
71
98
  * but a schema written before the sanitizer stripped trailing symbols can carry
@@ -22,7 +22,7 @@
22
22
  * cannot compile.
23
23
  */
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
- exports.RESERVED_FIELD_ACCESSORS = exports.RESERVED_IDENTIFIERS = exports.RESERVED_TABLE_ACCESSORS = void 0;
25
+ exports.SdkNameAllocator = exports.RESERVED_FIELD_ACCESSORS = exports.RESERVED_IDENTIFIERS = exports.RESERVED_TABLE_ACCESSORS = void 0;
26
26
  exports.toPascalCase = toPascalCase;
27
27
  exports.toCamelCase = toCamelCase;
28
28
  exports.toSdkName = toSdkName;
@@ -146,6 +146,7 @@ class SdkNameAllocator {
146
146
  throw new Error(`Could not allocate a unique SDK name for "${preferred}" after ${MAX_SUFFIX_ATTEMPTS} attempts`);
147
147
  }
148
148
  }
149
+ exports.SdkNameAllocator = SdkNameAllocator;
149
150
  /**
150
151
  * Existing sdkNames are preserved across syncs so user code keeps compiling,
151
152
  * but a schema written before the sanitizer stripped trailing symbols can carry
@@ -207,3 +207,31 @@ const recordTypeKeys = (source, typeName) => {
207
207
  (0, vitest_1.expect)(keys).toContain("sql2");
208
208
  });
209
209
  });
210
+ (0, vitest_1.describe)("table display names", () => {
211
+ // `sdkName` is locked to the id so user code keeps compiling; `name` is the
212
+ // opposite and must always take the live value. Locking it too — the easy
213
+ // mistake, since the line above it does exactly that — freezes it at the
214
+ // table's first name and defeats the point.
215
+ (0, vitest_1.it)("follows a rename while the sdkName stays locked", () => {
216
+ const first = (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: "Slots", fields: [field("fld_1", "At")] }]));
217
+ (0, vitest_1.expect)(first.tables[0].sdkName).toBe("slots");
218
+ (0, vitest_1.expect)(first.tables[0].name).toBe("Slots");
219
+ const renamed = (0, lib_js_1.generateSchema)(database([
220
+ { id: "tbl_1", name: "Time Slots", fields: [field("fld_1", "At")] },
221
+ ]), first);
222
+ (0, vitest_1.expect)(renamed.tables[0].sdkName).toBe("slots");
223
+ (0, vitest_1.expect)(renamed.tables[0].name).toBe("Time Slots");
224
+ (0, vitest_1.expect)((0, lib_js_1.generateDbTs)(renamed)).toContain('/** A record in the "Time Slots" table, as it is read back. */');
225
+ });
226
+ // `buildLegacySchemaSeed` (restly, migration) builds a seed carrying only
227
+ // `sdkName`. Reading `name` off the seed would emit `undefined` for every
228
+ // table on every newly migrated project.
229
+ (0, vitest_1.it)("takes the live name even when the seed has none", () => {
230
+ const seed = {
231
+ tables: [{ id: "tbl_1", sdkName: "salesDeals", fields: [] }],
232
+ };
233
+ const schema = (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: "Deals", fields: [] }]), seed);
234
+ (0, vitest_1.expect)(schema.tables[0].sdkName).toBe("salesDeals");
235
+ (0, vitest_1.expect)(schema.tables[0].name).toBe("Deals");
236
+ });
237
+ });
@@ -61,7 +61,13 @@ export type ZiteStreamInterface = {
61
61
  export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {
62
62
  description?: string;
63
63
  inputSchema?: SchemaLike<TInput, TRawInput>;
64
- outputSchema?: SchemaLike<TOutput>;
64
+ /**
65
+ * Documentation only — never validated, and `unknown` so it cannot compete
66
+ * with `execute` for `TOutput`. A nullable column reads as `T | undefined`
67
+ * while its schema says `.nullable()`; making both inference sites failed the
68
+ * build on the difference.
69
+ */
70
+ outputSchema?: SchemaLike<unknown>;
65
71
  stream?: TStream;
66
72
  authenticated?: boolean;
67
73
  /**
@@ -1,5 +1,6 @@
1
1
  import type { PublicFieldDefinition } from "../types/fields.js";
2
2
  import type { Database } from "../types/tables.js";
3
+ import { type SdkNameRename } from "./sdkNames.js";
3
4
  export type ZiteSchemaField = {
4
5
  id: string;
5
6
  sdkName: string;
@@ -8,6 +9,7 @@ export type ZiteSchemaField = {
8
9
  export type ZiteSchemaTable = {
9
10
  id: string;
10
11
  sdkName: string;
12
+ name?: string;
11
13
  primaryFieldId?: string;
12
14
  fields: ZiteSchemaField[];
13
15
  };
@@ -44,6 +46,7 @@ export type AirtableLockField = {
44
46
  export type AirtableLockTable = {
45
47
  id: string;
46
48
  sdkName: string;
49
+ name?: string;
47
50
  primaryFieldId: string;
48
51
  fields: AirtableLockField[];
49
52
  };
@@ -51,7 +54,33 @@ export type AirtableLock = {
51
54
  integrationId: string;
52
55
  tables: AirtableLockTable[];
53
56
  };
54
- export declare function generateAirtableTs(lock: AirtableLock): string | null;
57
+ /**
58
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
59
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
60
+ * exclude.
61
+ *
62
+ * A contested name goes to NOBODY. The lock cannot say which table app code
63
+ * meant, and lock order is no oracle: a rename leaves the original owner
64
+ * first, while reordering or multi-base flattening can put the newcomer
65
+ * first. Guessing wrong would keep stale references compiling against the
66
+ * other table's data (the client is addressed by `tableId`, so the binding
67
+ * follows whichever table holds the name). With the bare name gone, a stale
68
+ * reference fails as an unresolved import instead: visible and fixable.
69
+ *
70
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
71
+ * server from stored nameMappings that a generate-time rename can never
72
+ * update, so a renamed field would type-check yet read `undefined` and fail
73
+ * every write. Unlike tables, duplicates cannot form there: the producer
74
+ * seeds field allocation from the stored mappings.
75
+ *
76
+ * Names are assigned in declaration order, so an unchanged lock regenerates
77
+ * the identical file.
78
+ */
79
+ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
80
+ lock: AirtableLock;
81
+ renames: SdkNameRename[];
82
+ };
83
+ export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
55
84
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
56
85
  /**
57
86
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
@@ -1,5 +1,5 @@
1
1
  import { parse } from "@babel/parser";
2
- import { allocateSchemaNames, describeRenames, keepValidSdkName, normalizeSchemaNames, toCamelCase, toPascalCase, toSdkName, } from "./sdkNames.js";
2
+ import { allocateSchemaNames, describeRenames, keepValidSdkName, normalizeSchemaNames, SdkNameAllocator, toCamelCase, toPascalCase, toSdkName, } from "./sdkNames.js";
3
3
  const AUTH_USERS_TABLE_ID = "zite_user";
4
4
  /**
5
5
  * What a field's value looks like when a record is READ back.
@@ -255,9 +255,12 @@ function fieldJsdoc(schemaField, table, schema) {
255
255
  const tpl = def.template;
256
256
  if (tpl.tableId) {
257
257
  // The SDK name, not the raw `tbl...` id — the id appears nowhere the
258
- // reader can act on, while the SDK name is the property on `zite`.
258
+ // reader can act on, while the SDK name is the property on `zite`. Its
259
+ // display name rides along, since after a rename the two differ.
259
260
  const linked = schema?.tables.find((t) => t.id === tpl.tableId);
260
- parts.push(`Links to ${linked ? linked.sdkName : tpl.tableId}`);
261
+ parts.push(linked
262
+ ? `Links to ${linked.sdkName}${linked.name ? ` ("${linked.name}")` : ""}`
263
+ : `Links to ${tpl.tableId}`);
261
264
  }
262
265
  if (tpl.allowMultiple === false)
263
266
  parts.push("Single record only");
@@ -370,6 +373,10 @@ export function generateSchema(database, existingSchema) {
370
373
  table: {
371
374
  id: table.id,
372
375
  sdkName: "",
376
+ // Always the live name — never `existingTable?.name`. Locking it the way
377
+ // `sdkName` is locked would freeze it at the first name the table ever
378
+ // had, which is the one thing this is here to avoid.
379
+ name: table.name,
373
380
  primaryFieldId: table.primaryFieldId,
374
381
  fields: [],
375
382
  },
@@ -428,6 +435,13 @@ function buildLinkTableComments(schema) {
428
435
  const tablesById = new Map();
429
436
  for (const t of schema.tables)
430
437
  tablesById.set(t.id, t);
438
+ // The link name itself is a literal SQL identifier ("use these exact names"),
439
+ // so it stays sdkName-derived; the display names ride alongside as a gloss.
440
+ const displayByPascal = new Map();
441
+ for (const t of schema.tables) {
442
+ if (t.name)
443
+ displayByPascal.set(toPascalCase(t.sdkName), t.name);
444
+ }
431
445
  const seen = new Set();
432
446
  const entries = [];
433
447
  for (const table of schema.tables) {
@@ -457,7 +471,15 @@ function buildLinkTableComments(schema) {
457
471
  const col2 = isSelf
458
472
  ? `target${second}Id`
459
473
  : `${second.charAt(0).toLowerCase()}${second.slice(1)}Id`;
460
- entries.push({ name: linkName, cols: [col1, col2] });
474
+ const firstName = displayByPascal.get(first);
475
+ const secondName = displayByPascal.get(second);
476
+ entries.push({
477
+ name: linkName,
478
+ cols: [col1, col2],
479
+ gloss: firstName && secondName
480
+ ? `joins "${firstName}" and "${secondName}"`
481
+ : undefined,
482
+ });
461
483
  }
462
484
  }
463
485
  if (entries.length === 0)
@@ -467,7 +489,8 @@ function buildLinkTableComments(schema) {
467
489
  "// Link tables for zite.sql() JOINs (use these exact names):",
468
490
  ];
469
491
  for (const e of entries) {
470
- lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
492
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"` +
493
+ (e.gloss ? ` (${commentSafe(e.gloss)})` : ""));
471
494
  }
472
495
  return lines;
473
496
  }
@@ -554,7 +577,12 @@ export function generateDbTs(inputSchema) {
554
577
  for (const table of tables) {
555
578
  const className = toPascalCase(table.sdkName);
556
579
  const recordType = `${className}RecordType`;
557
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
580
+ // A schema written before `name` existed keeps the old wording exactly —
581
+ // sandbox boot regenerates unconditionally, so any drift here would show up
582
+ // as an unexplained diff in every project's next commit.
583
+ lines.push(table.name
584
+ ? `/** A record in the "${commentSafe(table.name)}" table, as it is read back. */`
585
+ : `/** A ${table.sdkName} record as it is read back. */`);
558
586
  lines.push(`export type ${recordType} = {`);
559
587
  lines.push(" id: string;");
560
588
  for (const field of table.fields) {
@@ -578,7 +606,9 @@ export function generateDbTs(inputSchema) {
578
606
  // input than they store (an attachments field takes a URL string; a linked
579
607
  // record takes one id or many).
580
608
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_FIELD_TYPES.has(f.definition.type));
581
- lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
609
+ lines.push(table.name
610
+ ? `/** What you may write when creating or updating a record in the "${commentSafe(table.name)}" table. */`
611
+ : `/** What you may write when creating or updating a ${table.sdkName}. */`);
582
612
  lines.push(`export type ${className}RecordInput = {`);
583
613
  for (const field of writableFields) {
584
614
  lines.push(` ${field.sdkName}: ${tsTypeForSchemaField(field.definition, "write")};`);
@@ -590,7 +620,8 @@ export function generateDbTs(inputSchema) {
590
620
  lines.push("export const zite = {");
591
621
  for (const table of tables) {
592
622
  const className = toPascalCase(table.sdkName);
593
- lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
623
+ lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),` +
624
+ (table.name ? ` // "${commentSafe(table.name)}"` : ""));
594
625
  }
595
626
  lines.push(` sql: createSqlClient(),`);
596
627
  // No `notifications` here: it is a platform primitive, not something backed
@@ -989,7 +1020,9 @@ function airtableFieldJsdoc(field, table, lock) {
989
1020
  const linkedTableId = field.config?.linkedTableId;
990
1021
  if (linkedTableId) {
991
1022
  const linked = lock?.tables.find((t) => t.id === linkedTableId);
992
- parts.push(`Links to ${linked ? linked.sdkName : linkedTableId}`);
1023
+ parts.push(linked
1024
+ ? `Links to ${linked.sdkName}${linked.name ? ` ("${linked.name}")` : ""}`
1025
+ : `Links to ${linkedTableId}`);
993
1026
  }
994
1027
  if (field.config?.prefersSingleRecordLink)
995
1028
  parts.push("Single record only");
@@ -1042,9 +1075,71 @@ function airtableFieldJsdoc(field, table, lock) {
1042
1075
  return undefined;
1043
1076
  return parts.join(". ");
1044
1077
  }
1045
- export function generateAirtableTs(lock) {
1046
- if (lock.tables.length === 0)
1078
+ // A table client is a `const`, so only value-space bindings can collide with
1079
+ // one. `AirtableAttachment` is a type, and types and consts coexist.
1080
+ const RESERVED_AIRTABLE_NAMES = ["createAirtableClient"];
1081
+ /**
1082
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
1083
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
1084
+ * exclude.
1085
+ *
1086
+ * A contested name goes to NOBODY. The lock cannot say which table app code
1087
+ * meant, and lock order is no oracle: a rename leaves the original owner
1088
+ * first, while reordering or multi-base flattening can put the newcomer
1089
+ * first. Guessing wrong would keep stale references compiling against the
1090
+ * other table's data (the client is addressed by `tableId`, so the binding
1091
+ * follows whichever table holds the name). With the bare name gone, a stale
1092
+ * reference fails as an unresolved import instead: visible and fixable.
1093
+ *
1094
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
1095
+ * server from stored nameMappings that a generate-time rename can never
1096
+ * update, so a renamed field would type-check yet read `undefined` and fail
1097
+ * every write. Unlike tables, duplicates cannot form there: the producer
1098
+ * seeds field allocation from the stored mappings.
1099
+ *
1100
+ * Names are assigned in declaration order, so an unchanged lock regenerates
1101
+ * the identical file.
1102
+ */
1103
+ export function normalizeAirtableLockNames(lock) {
1104
+ const owners = new Map();
1105
+ for (const table of lock.tables) {
1106
+ owners.set(table.sdkName, (owners.get(table.sdkName) ?? 0) + 1);
1107
+ }
1108
+ const contested = [...owners.entries()]
1109
+ .filter(([, count]) => count > 1)
1110
+ .map(([name]) => name);
1111
+ const tableNames = new SdkNameAllocator({
1112
+ reserved: [...RESERVED_AIRTABLE_NAMES, ...contested],
1113
+ });
1114
+ // Sole owners claim their names before any contender is suffixed, so a
1115
+ // contender can never displace a table legitimately named `GroupCalls2`.
1116
+ const kept = lock.tables.map((table) => owners.get(table.sdkName) === 1 && tableNames.available(table.sdkName)
1117
+ ? tableNames.allocate(table.sdkName)
1118
+ : undefined);
1119
+ const renames = [];
1120
+ const tables = lock.tables.map((table, i) => {
1121
+ const sdkName = kept[i] ?? tableNames.allocate(table.sdkName);
1122
+ if (sdkName !== table.sdkName) {
1123
+ renames.push({
1124
+ kind: "table",
1125
+ tableId: table.id,
1126
+ from: table.sdkName,
1127
+ to: sdkName,
1128
+ });
1129
+ }
1130
+ return { ...table, sdkName };
1131
+ });
1132
+ return { lock: { ...lock, tables }, renames };
1133
+ }
1134
+ export function generateAirtableTs(inputLock) {
1135
+ if (inputLock.tables.length === 0)
1047
1136
  return null;
1137
+ const { lock, renames } = normalizeAirtableLockNames(inputLock);
1138
+ if (renames.length > 0) {
1139
+ for (const line of describeRenames(renames)) {
1140
+ console.warn(`[zitejs] SDK name conflict in zite.lock; the generated Airtable SDK uses: ${line}`);
1141
+ }
1142
+ }
1048
1143
  const lines = [
1049
1144
  "// Auto-generated by zitejs generate from zite.lock. Do not edit manually.",
1050
1145
  "// Airtable SDK — uses createAirtableClient from zitejs/runtime.",
@@ -1085,7 +1180,9 @@ export function generateAirtableTs(lock) {
1085
1180
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1086
1181
  for (const table of lock.tables) {
1087
1182
  const recordType = `${table.sdkName}RecordType`;
1088
- lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1183
+ lines.push(table.name
1184
+ ? `/** A record in the "${commentSafe(table.name)}" table, as it is read back. */`
1185
+ : `/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1089
1186
  lines.push(`export type ${recordType} = {`);
1090
1187
  lines.push(" id: string;");
1091
1188
  for (const field of table.fields) {
@@ -1107,7 +1204,9 @@ export function generateAirtableTs(lock) {
1107
1204
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1108
1205
  // to a formula, rollup, lookup or autonumber with a 422.
1109
1206
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
1110
- lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1207
+ lines.push(table.name
1208
+ ? `/** What you may write when creating or updating a record in the "${commentSafe(table.name)}" table. */`
1209
+ : `/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1111
1210
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1112
1211
  for (const field of writableFields) {
1113
1212
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1117,7 +1216,8 @@ export function generateAirtableTs(lock) {
1117
1216
  lines.push(`export const ${table.sdkName} = createAirtableClient<${recordType}, ${table.sdkName}RecordInput>(`);
1118
1217
  lines.push(` '${lock.integrationId}',`);
1119
1218
  lines.push(` '${table.sdkName}',`);
1120
- lines.push(` { tableId: '${table.id}' },`);
1219
+ lines.push(` { tableId: '${table.id}' },` +
1220
+ (table.name ? ` // "${commentSafe(table.name)}"` : ""));
1121
1221
  lines.push(`);`);
1122
1222
  lines.push("");
1123
1223
  }
@@ -1213,7 +1313,9 @@ export function generateBackendWrapperTs(envVarNames = []) {
1213
1313
  "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
1214
1314
  " description?: string;",
1215
1315
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
1216
- " outputSchema?: SchemaLike<TOutput>;",
1316
+ // Apps compile against this copy, not the base module's.
1317
+ " /** Documentation only. Never validated, and not an inference site for TOutput. */",
1318
+ " outputSchema?: SchemaLike<unknown>;",
1217
1319
  " stream?: TStream;",
1218
1320
  " authenticated?: boolean;",
1219
1321
  " /** 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. */",
@@ -46,6 +46,173 @@ describe('generateDbTs select option literals', () => {
46
46
  expect(out).toContain('"The \\"Premium\\" tier"');
47
47
  });
48
48
  });
49
+ /** TS2300/TS2451, which `syntaxErrorsIn` can't see — a duplicate export parses fine. */
50
+ const duplicateIdentifierErrorsIn = (source) => {
51
+ const AMBIENT = `declare module 'zitejs/runtime' {
52
+ export function createAirtableClient<T, TInput = T>(
53
+ integrationId: string,
54
+ className: string,
55
+ implicitParams: Record<string, unknown>,
56
+ ): unknown;
57
+ }`;
58
+ const sources = {
59
+ 'ambient.d.ts': AMBIENT,
60
+ 'airtable.ts': source,
61
+ };
62
+ const host = ts.createCompilerHost({});
63
+ const original = host.getSourceFile.bind(host);
64
+ host.getSourceFile = (fileName, langVersion, onError, shouldCreate) => sources[fileName] !== undefined
65
+ ? ts.createSourceFile(fileName, sources[fileName], langVersion, true)
66
+ : original(fileName, langVersion, onError, shouldCreate);
67
+ host.fileExists = f => sources[f] !== undefined || ts.sys.fileExists(f);
68
+ host.readFile = f => sources[f] ?? ts.sys.readFile(f);
69
+ const program = ts.createProgram(Object.keys(sources), { strict: true, noEmit: true, skipLibCheck: true }, host);
70
+ return ts
71
+ .getPreEmitDiagnostics(program)
72
+ .filter(d => d.code === 2300 || d.code === 2451)
73
+ .map(d => ts.flattenDiagnosticMessageText(d.messageText, ' '));
74
+ };
75
+ describe('generateAirtableTs duplicate SDK names', () => {
76
+ // ZIT-5032: two tables both resolved to `GroupCalls`.
77
+ const collidingLock = (name) => ({
78
+ integrationId: 'airtable',
79
+ tables: [
80
+ {
81
+ id: 'tblV204b5ogVasPKc',
82
+ sdkName: name,
83
+ primaryFieldId: 'fld1',
84
+ fields: [
85
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
86
+ ],
87
+ },
88
+ {
89
+ id: 'tblt474V0WyNlrHX9',
90
+ sdkName: name,
91
+ primaryFieldId: 'fld2',
92
+ fields: [
93
+ { id: 'fld2', sdkName: 'title', type: 'singleLineText', name: 'Title' },
94
+ ],
95
+ },
96
+ ],
97
+ });
98
+ it('compiles when two tables resolve to the same SDK name', () => {
99
+ expect(duplicateIdentifierErrorsIn(generateAirtableTs(collidingLock('GroupCalls')) ?? '')).toEqual([]);
100
+ });
101
+ // Lock order cannot say which table originally owned the name, and handing
102
+ // it to the wrong one would leave stale references compiling against the
103
+ // other table's data. Suffixing every contender turns them into unresolved
104
+ // imports instead.
105
+ it('gives a contested name to neither table', () => {
106
+ const out = generateAirtableTs(collidingLock('GroupCalls')) ?? '';
107
+ expect(out).not.toContain('export const GroupCalls = ');
108
+ expect(out).toContain('export const GroupCalls2 = ');
109
+ expect(out).toContain('export const GroupCalls3 = ');
110
+ });
111
+ it('still addresses each table by its own id, so data access is unchanged', () => {
112
+ const out = generateAirtableTs(collidingLock('GroupCalls')) ?? '';
113
+ expect(out).toContain("'GroupCalls2',\n { tableId: 'tblV204b5ogVasPKc' }");
114
+ expect(out).toContain("'GroupCalls3',\n { tableId: 'tblt474V0WyNlrHX9' }");
115
+ });
116
+ it('never displaces the legitimate owner of a suffixed name', () => {
117
+ const table = (id, sdkName) => ({
118
+ id,
119
+ sdkName,
120
+ primaryFieldId: 'fld1',
121
+ fields: [
122
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
123
+ ],
124
+ });
125
+ const out = generateAirtableTs({
126
+ integrationId: 'airtable',
127
+ tables: [
128
+ table('tblA', 'GroupCalls'),
129
+ table('tblB', 'GroupCalls'),
130
+ table('tblC', 'GroupCalls2'),
131
+ ],
132
+ }) ?? '';
133
+ expect(out).toContain("'GroupCalls2',\n { tableId: 'tblC' }");
134
+ expect(out).toContain('export const GroupCalls3 = ');
135
+ expect(out).toContain('export const GroupCalls4 = ');
136
+ expect(out).not.toContain('export const GroupCalls = ');
137
+ expect(duplicateIdentifierErrorsIn(out)).toEqual([]);
138
+ });
139
+ it('does not rename anything when names are already unique', () => {
140
+ const out = generateAirtableTs({
141
+ integrationId: 'airtable',
142
+ tables: [
143
+ {
144
+ id: 'tbl1',
145
+ sdkName: 'Calls',
146
+ primaryFieldId: 'fld1',
147
+ fields: [
148
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
149
+ ],
150
+ },
151
+ ],
152
+ }) ?? '';
153
+ expect(out).toContain('export const Calls = ');
154
+ expect(out).not.toContain('Calls2');
155
+ });
156
+ // A field's sdkName is a wire key: the server resolves it from the stored
157
+ // nameMappings, which a generate-time rename can never update. A renamed
158
+ // field would type-check yet read `undefined` and fail every write, so
159
+ // fields are never renamed here.
160
+ it('leaves field names alone, even when they collide', () => {
161
+ const out = generateAirtableTs({
162
+ integrationId: 'airtable',
163
+ tables: [
164
+ {
165
+ id: 'tbl1',
166
+ sdkName: 'Calls',
167
+ primaryFieldId: 'fldA',
168
+ fields: [
169
+ { id: 'fldA', sdkName: 'notes', type: 'singleLineText', name: 'Notes' },
170
+ { id: 'fldB', sdkName: 'notes', type: 'singleLineText', name: 'Notes copy' },
171
+ ],
172
+ },
173
+ ],
174
+ }) ?? '';
175
+ expect(out).not.toContain('notes2');
176
+ });
177
+ it('still skips a field named `id` instead of renaming it', () => {
178
+ const out = generateAirtableTs({
179
+ integrationId: 'airtable',
180
+ tables: [
181
+ {
182
+ id: 'tbl1',
183
+ sdkName: 'Calls',
184
+ primaryFieldId: 'fldA',
185
+ fields: [
186
+ { id: 'fldA', sdkName: 'id', type: 'singleLineText', name: 'ID' },
187
+ { id: 'fldB', sdkName: 'title', type: 'singleLineText', name: 'Title' },
188
+ ],
189
+ },
190
+ ],
191
+ }) ?? '';
192
+ expect(out).not.toContain('id2');
193
+ expect(out).not.toContain('id?:');
194
+ // the record id, emitted by hand, stays the only `id`
195
+ expect(out).toContain('id: string;');
196
+ });
197
+ it('leaves a table named after the attachment type alone', () => {
198
+ const out = generateAirtableTs({
199
+ integrationId: 'airtable',
200
+ tables: [
201
+ {
202
+ id: 'tbl1',
203
+ sdkName: 'AirtableAttachment',
204
+ primaryFieldId: 'fld1',
205
+ fields: [
206
+ { id: 'fld1', sdkName: 'title', type: 'singleLineText', name: 'Title' },
207
+ ],
208
+ },
209
+ ],
210
+ }) ?? '';
211
+ expect(duplicateIdentifierErrorsIn(out)).toEqual([]);
212
+ expect(out).toContain('export const AirtableAttachment = ');
213
+ expect(out).not.toContain('AirtableAttachment2');
214
+ });
215
+ });
49
216
  describe('generateAirtableTs attachment type', () => {
50
217
  const lockWithAttachment = {
51
218
  integrationId: 'airtable',
@@ -110,6 +277,56 @@ describe('generateDbTs field names in comments', () => {
110
277
  expect(syntaxErrorsIn(generateDbTs(schemaWithFieldNamed(name)))).toEqual([]);
111
278
  });
112
279
  });
280
+ describe('generateDbTs table names in comments', () => {
281
+ const schemaWithTableNamed = (name) => ({
282
+ tables: [
283
+ {
284
+ id: 'tbl1',
285
+ sdkName: 'table1',
286
+ ...(name === undefined ? {} : { name }),
287
+ primaryFieldId: 'fld1',
288
+ fields: [
289
+ {
290
+ id: 'fld1',
291
+ sdkName: 'slot',
292
+ definition: {
293
+ type: 'single_line_text',
294
+ name: 'Slot',
295
+ template: {},
296
+ },
297
+ },
298
+ ],
299
+ },
300
+ ],
301
+ });
302
+ // The whole point: `sdkName` is locked to the table's id, so after a rename
303
+ // `table1` is the only thing a reader sees. The display name is what says
304
+ // which table that is.
305
+ it('names the table beside its locked sdkName', () => {
306
+ const out = generateDbTs(schemaWithTableNamed('Time Slots'));
307
+ expect(out).toContain('/** A record in the "Time Slots" table, as it is read back. */');
308
+ expect(out).toContain('/** What you may write when creating or updating a record in the "Time Slots" table. */');
309
+ expect(out).toContain(`table1: createTableClient<Table1RecordType, Table1RecordInput>('Table1'), // "Time Slots"`);
310
+ });
311
+ // Sandbox boot regenerates unconditionally against the committed schema, so a
312
+ // schema written before `name` existed has to keep producing what it did
313
+ // before — otherwise every project's next commit carries an unexplained diff.
314
+ it('emits the pre-name wording when the schema carries no name', () => {
315
+ const out = generateDbTs(schemaWithTableNamed(undefined));
316
+ expect(out).toContain('/** A table1 record as it is read back. */');
317
+ expect(out).toContain('/** What you may write when creating or updating a table1. */');
318
+ expect(out).toContain(`('Table1'),\n`);
319
+ expect(out).not.toContain('" table, as it is read back');
320
+ });
321
+ // Same hazard as a field name, one level up: a table's display name is user
322
+ // text and now reaches a JSDoc.
323
+ it.each([
324
+ ['a comment-close sequence', 'Slots */ console.log(1); /*'],
325
+ ['a newline', 'Time\nSlots'],
326
+ ])('emits parseable TypeScript for a table named with %s', (_what, name) => {
327
+ expect(syntaxErrorsIn(generateDbTs(schemaWithTableNamed(name)))).toEqual([]);
328
+ });
329
+ });
113
330
  describe('generateApiTs endpoint file names', () => {
114
331
  // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
115
332
  // validates its characters — and it lands in an import specifier, a comment
@@ -66,6 +66,33 @@ export interface SdkNameRename {
66
66
  from: string;
67
67
  to: string;
68
68
  }
69
+ /**
70
+ * Unique identifiers within one namespace.
71
+ *
72
+ * `derive` names the extra keys an allocation also occupies. A table needs it:
73
+ * its sdkName becomes both a property on `zite` and, PascalCased, a pair of
74
+ * exported type declarations, so `orders` and `Orders` are distinct accessors
75
+ * that would emit `OrdersRecordType` twice.
76
+ */
77
+ export declare class SdkNameAllocator {
78
+ private readonly taken;
79
+ private readonly reserved;
80
+ private readonly derive;
81
+ constructor(opts?: {
82
+ reserved?: readonly string[];
83
+ derive?: (name: string) => string[];
84
+ });
85
+ private keysFor;
86
+ private isFree;
87
+ /** Free right now, without claiming it. */
88
+ available(name: string): boolean;
89
+ private claim;
90
+ /**
91
+ * `name`, or the first `name2`, `name3`… that is free. Suffixes start at 2
92
+ * because that is what the name means: the second table called Notifications.
93
+ */
94
+ allocate(preferred: string): string;
95
+ }
69
96
  /**
70
97
  * Existing sdkNames are preserved across syncs so user code keeps compiling,
71
98
  * but a schema written before the sanitizer stripped trailing symbols can carry
@@ -91,7 +91,7 @@ const MAX_SUFFIX_ATTEMPTS = 10_000;
91
91
  * exported type declarations, so `orders` and `Orders` are distinct accessors
92
92
  * that would emit `OrdersRecordType` twice.
93
93
  */
94
- class SdkNameAllocator {
94
+ export class SdkNameAllocator {
95
95
  taken = new Set();
96
96
  reserved;
97
97
  derive;
@@ -205,3 +205,31 @@ describe("normalizeSchemaNames", () => {
205
205
  expect(keys).toContain("sql2");
206
206
  });
207
207
  });
208
+ describe("table display names", () => {
209
+ // `sdkName` is locked to the id so user code keeps compiling; `name` is the
210
+ // opposite and must always take the live value. Locking it too — the easy
211
+ // mistake, since the line above it does exactly that — freezes it at the
212
+ // table's first name and defeats the point.
213
+ it("follows a rename while the sdkName stays locked", () => {
214
+ const first = generateSchema(database([{ id: "tbl_1", name: "Slots", fields: [field("fld_1", "At")] }]));
215
+ expect(first.tables[0].sdkName).toBe("slots");
216
+ expect(first.tables[0].name).toBe("Slots");
217
+ const renamed = generateSchema(database([
218
+ { id: "tbl_1", name: "Time Slots", fields: [field("fld_1", "At")] },
219
+ ]), first);
220
+ expect(renamed.tables[0].sdkName).toBe("slots");
221
+ expect(renamed.tables[0].name).toBe("Time Slots");
222
+ expect(generateDbTs(renamed)).toContain('/** A record in the "Time Slots" table, as it is read back. */');
223
+ });
224
+ // `buildLegacySchemaSeed` (restly, migration) builds a seed carrying only
225
+ // `sdkName`. Reading `name` off the seed would emit `undefined` for every
226
+ // table on every newly migrated project.
227
+ it("takes the live name even when the seed has none", () => {
228
+ const seed = {
229
+ tables: [{ id: "tbl_1", sdkName: "salesDeals", fields: [] }],
230
+ };
231
+ const schema = generateSchema(database([{ id: "tbl_1", name: "Deals", fields: [] }]), seed);
232
+ expect(schema.tables[0].sdkName).toBe("salesDeals");
233
+ expect(schema.tables[0].name).toBe("Deals");
234
+ });
235
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.110",
3
+ "version": "0.9.112",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",