zitejs 0.9.110 → 0.9.111

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;
@@ -51,7 +52,33 @@ export type AirtableLock = {
51
52
  integrationId: string;
52
53
  tables: AirtableLockTable[];
53
54
  };
54
- export declare function generateAirtableTs(lock: AirtableLock): string | null;
55
+ /**
56
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
57
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
58
+ * exclude.
59
+ *
60
+ * A contested name goes to NOBODY. The lock cannot say which table app code
61
+ * meant, and lock order is no oracle: a rename leaves the original owner
62
+ * first, while reordering or multi-base flattening can put the newcomer
63
+ * first. Guessing wrong would keep stale references compiling against the
64
+ * other table's data (the client is addressed by `tableId`, so the binding
65
+ * follows whichever table holds the name). With the bare name gone, a stale
66
+ * reference fails as an unresolved import instead: visible and fixable.
67
+ *
68
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
69
+ * server from stored nameMappings that a generate-time rename can never
70
+ * update, so a renamed field would type-check yet read `undefined` and fail
71
+ * every write. Unlike tables, duplicates cannot form there: the producer
72
+ * seeds field allocation from the stored mappings.
73
+ *
74
+ * Names are assigned in declaration order, so an unchanged lock regenerates
75
+ * the identical file.
76
+ */
77
+ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
78
+ lock: AirtableLock;
79
+ renames: SdkNameRename[];
80
+ };
81
+ export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
55
82
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
56
83
  /**
57
84
  * 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;
@@ -1054,9 +1055,71 @@ function airtableFieldJsdoc(field, table, lock) {
1054
1055
  return undefined;
1055
1056
  return parts.join(". ");
1056
1057
  }
1057
- function generateAirtableTs(lock) {
1058
- if (lock.tables.length === 0)
1058
+ // A table client is a `const`, so only value-space bindings can collide with
1059
+ // one. `AirtableAttachment` is a type, and types and consts coexist.
1060
+ const RESERVED_AIRTABLE_NAMES = ["createAirtableClient"];
1061
+ /**
1062
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
1063
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
1064
+ * exclude.
1065
+ *
1066
+ * A contested name goes to NOBODY. The lock cannot say which table app code
1067
+ * meant, and lock order is no oracle: a rename leaves the original owner
1068
+ * first, while reordering or multi-base flattening can put the newcomer
1069
+ * first. Guessing wrong would keep stale references compiling against the
1070
+ * other table's data (the client is addressed by `tableId`, so the binding
1071
+ * follows whichever table holds the name). With the bare name gone, a stale
1072
+ * reference fails as an unresolved import instead: visible and fixable.
1073
+ *
1074
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
1075
+ * server from stored nameMappings that a generate-time rename can never
1076
+ * update, so a renamed field would type-check yet read `undefined` and fail
1077
+ * every write. Unlike tables, duplicates cannot form there: the producer
1078
+ * seeds field allocation from the stored mappings.
1079
+ *
1080
+ * Names are assigned in declaration order, so an unchanged lock regenerates
1081
+ * the identical file.
1082
+ */
1083
+ function normalizeAirtableLockNames(lock) {
1084
+ const owners = new Map();
1085
+ for (const table of lock.tables) {
1086
+ owners.set(table.sdkName, (owners.get(table.sdkName) ?? 0) + 1);
1087
+ }
1088
+ const contested = [...owners.entries()]
1089
+ .filter(([, count]) => count > 1)
1090
+ .map(([name]) => name);
1091
+ const tableNames = new sdkNames_js_1.SdkNameAllocator({
1092
+ reserved: [...RESERVED_AIRTABLE_NAMES, ...contested],
1093
+ });
1094
+ // Sole owners claim their names before any contender is suffixed, so a
1095
+ // contender can never displace a table legitimately named `GroupCalls2`.
1096
+ const kept = lock.tables.map((table) => owners.get(table.sdkName) === 1 && tableNames.available(table.sdkName)
1097
+ ? tableNames.allocate(table.sdkName)
1098
+ : undefined);
1099
+ const renames = [];
1100
+ const tables = lock.tables.map((table, i) => {
1101
+ const sdkName = kept[i] ?? tableNames.allocate(table.sdkName);
1102
+ if (sdkName !== table.sdkName) {
1103
+ renames.push({
1104
+ kind: "table",
1105
+ tableId: table.id,
1106
+ from: table.sdkName,
1107
+ to: sdkName,
1108
+ });
1109
+ }
1110
+ return { ...table, sdkName };
1111
+ });
1112
+ return { lock: { ...lock, tables }, renames };
1113
+ }
1114
+ function generateAirtableTs(inputLock) {
1115
+ if (inputLock.tables.length === 0)
1059
1116
  return null;
1117
+ const { lock, renames } = normalizeAirtableLockNames(inputLock);
1118
+ if (renames.length > 0) {
1119
+ for (const line of (0, sdkNames_js_1.describeRenames)(renames)) {
1120
+ console.warn(`[zitejs] SDK name conflict in zite.lock; the generated Airtable SDK uses: ${line}`);
1121
+ }
1122
+ }
1060
1123
  const lines = [
1061
1124
  "// Auto-generated by zitejs generate from zite.lock. Do not edit manually.",
1062
1125
  "// Airtable SDK — uses createAirtableClient from zitejs/runtime.",
@@ -1225,7 +1288,9 @@ function generateBackendWrapperTs(envVarNames = []) {
1225
1288
  "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
1226
1289
  " description?: string;",
1227
1290
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
1228
- " outputSchema?: SchemaLike<TOutput>;",
1291
+ // Apps compile against this copy, not the base module's.
1292
+ " /** Documentation only. Never validated, and not an inference site for TOutput. */",
1293
+ " outputSchema?: SchemaLike<unknown>;",
1229
1294
  " stream?: TStream;",
1230
1295
  " authenticated?: boolean;",
1231
1296
  " /** 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',
@@ -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
@@ -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;
@@ -51,7 +52,33 @@ export type AirtableLock = {
51
52
  integrationId: string;
52
53
  tables: AirtableLockTable[];
53
54
  };
54
- export declare function generateAirtableTs(lock: AirtableLock): string | null;
55
+ /**
56
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
57
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
58
+ * exclude.
59
+ *
60
+ * A contested name goes to NOBODY. The lock cannot say which table app code
61
+ * meant, and lock order is no oracle: a rename leaves the original owner
62
+ * first, while reordering or multi-base flattening can put the newcomer
63
+ * first. Guessing wrong would keep stale references compiling against the
64
+ * other table's data (the client is addressed by `tableId`, so the binding
65
+ * follows whichever table holds the name). With the bare name gone, a stale
66
+ * reference fails as an unresolved import instead: visible and fixable.
67
+ *
68
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
69
+ * server from stored nameMappings that a generate-time rename can never
70
+ * update, so a renamed field would type-check yet read `undefined` and fail
71
+ * every write. Unlike tables, duplicates cannot form there: the producer
72
+ * seeds field allocation from the stored mappings.
73
+ *
74
+ * Names are assigned in declaration order, so an unchanged lock regenerates
75
+ * the identical file.
76
+ */
77
+ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
78
+ lock: AirtableLock;
79
+ renames: SdkNameRename[];
80
+ };
81
+ export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
55
82
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
56
83
  /**
57
84
  * 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.
@@ -1042,9 +1042,71 @@ function airtableFieldJsdoc(field, table, lock) {
1042
1042
  return undefined;
1043
1043
  return parts.join(". ");
1044
1044
  }
1045
- export function generateAirtableTs(lock) {
1046
- if (lock.tables.length === 0)
1045
+ // A table client is a `const`, so only value-space bindings can collide with
1046
+ // one. `AirtableAttachment` is a type, and types and consts coexist.
1047
+ const RESERVED_AIRTABLE_NAMES = ["createAirtableClient"];
1048
+ /**
1049
+ * Make every table sdkName in the lock unique, so a duplicated lock can't emit
1050
+ * a file that cannot compile (TS2300/TS2451) and that no app can edit or
1051
+ * exclude.
1052
+ *
1053
+ * A contested name goes to NOBODY. The lock cannot say which table app code
1054
+ * meant, and lock order is no oracle: a rename leaves the original owner
1055
+ * first, while reordering or multi-base flattening can put the newcomer
1056
+ * first. Guessing wrong would keep stale references compiling against the
1057
+ * other table's data (the client is addressed by `tableId`, so the binding
1058
+ * follows whichever table holds the name). With the bare name gone, a stale
1059
+ * reference fails as an unresolved import instead: visible and fixable.
1060
+ *
1061
+ * Field sdkNames are left untouched. They are wire keys, resolved by the
1062
+ * server from stored nameMappings that a generate-time rename can never
1063
+ * update, so a renamed field would type-check yet read `undefined` and fail
1064
+ * every write. Unlike tables, duplicates cannot form there: the producer
1065
+ * seeds field allocation from the stored mappings.
1066
+ *
1067
+ * Names are assigned in declaration order, so an unchanged lock regenerates
1068
+ * the identical file.
1069
+ */
1070
+ export function normalizeAirtableLockNames(lock) {
1071
+ const owners = new Map();
1072
+ for (const table of lock.tables) {
1073
+ owners.set(table.sdkName, (owners.get(table.sdkName) ?? 0) + 1);
1074
+ }
1075
+ const contested = [...owners.entries()]
1076
+ .filter(([, count]) => count > 1)
1077
+ .map(([name]) => name);
1078
+ const tableNames = new SdkNameAllocator({
1079
+ reserved: [...RESERVED_AIRTABLE_NAMES, ...contested],
1080
+ });
1081
+ // Sole owners claim their names before any contender is suffixed, so a
1082
+ // contender can never displace a table legitimately named `GroupCalls2`.
1083
+ const kept = lock.tables.map((table) => owners.get(table.sdkName) === 1 && tableNames.available(table.sdkName)
1084
+ ? tableNames.allocate(table.sdkName)
1085
+ : undefined);
1086
+ const renames = [];
1087
+ const tables = lock.tables.map((table, i) => {
1088
+ const sdkName = kept[i] ?? tableNames.allocate(table.sdkName);
1089
+ if (sdkName !== table.sdkName) {
1090
+ renames.push({
1091
+ kind: "table",
1092
+ tableId: table.id,
1093
+ from: table.sdkName,
1094
+ to: sdkName,
1095
+ });
1096
+ }
1097
+ return { ...table, sdkName };
1098
+ });
1099
+ return { lock: { ...lock, tables }, renames };
1100
+ }
1101
+ export function generateAirtableTs(inputLock) {
1102
+ if (inputLock.tables.length === 0)
1047
1103
  return null;
1104
+ const { lock, renames } = normalizeAirtableLockNames(inputLock);
1105
+ if (renames.length > 0) {
1106
+ for (const line of describeRenames(renames)) {
1107
+ console.warn(`[zitejs] SDK name conflict in zite.lock; the generated Airtable SDK uses: ${line}`);
1108
+ }
1109
+ }
1048
1110
  const lines = [
1049
1111
  "// Auto-generated by zitejs generate from zite.lock. Do not edit manually.",
1050
1112
  "// Airtable SDK — uses createAirtableClient from zitejs/runtime.",
@@ -1213,7 +1275,9 @@ export function generateBackendWrapperTs(envVarNames = []) {
1213
1275
  "export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput> {",
1214
1276
  " description?: string;",
1215
1277
  " inputSchema?: SchemaLike<TInput, TRawInput>;",
1216
- " outputSchema?: SchemaLike<TOutput>;",
1278
+ // Apps compile against this copy, not the base module's.
1279
+ " /** Documentation only. Never validated, and not an inference site for TOutput. */",
1280
+ " outputSchema?: SchemaLike<unknown>;",
1217
1281
  " stream?: TStream;",
1218
1282
  " authenticated?: boolean;",
1219
1283
  " /** 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',
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.110",
3
+ "version": "0.9.111",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",