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.
- package/dist/cjs/backend/index.d.ts +7 -1
- package/dist/cjs/sync/lib.d.ts +30 -1
- package/dist/cjs/sync/lib.js +117 -14
- package/dist/cjs/sync/lib.test.js +217 -0
- package/dist/cjs/sync/sdkNames.d.ts +27 -0
- package/dist/cjs/sync/sdkNames.js +2 -1
- package/dist/cjs/sync/sdkNames.test.js +28 -0
- package/dist/esm/backend/index.d.ts +7 -1
- package/dist/esm/sync/lib.d.ts +30 -1
- package/dist/esm/sync/lib.js +117 -15
- package/dist/esm/sync/lib.test.js +217 -0
- package/dist/esm/sync/sdkNames.d.ts +27 -0
- package/dist/esm/sync/sdkNames.js +1 -1
- package/dist/esm/sync/sdkNames.test.js +28 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
/**
|
package/dist/cjs/sync/lib.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/cjs/sync/lib.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1058
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/dist/esm/sync/lib.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/esm/sync/lib.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1046
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
+
});
|