zitejs 0.9.109 → 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.
- package/dist/cjs/backend/index.d.ts +7 -1
- package/dist/cjs/runtime/filterConditionTypes.test-d.d.ts +1 -0
- package/dist/cjs/runtime/filterConditionTypes.test-d.js +40 -0
- package/dist/cjs/runtime/index.d.ts +7 -2
- package/dist/cjs/sync/lib.d.ts +28 -1
- package/dist/cjs/sync/lib.js +68 -3
- package/dist/cjs/sync/lib.test.js +167 -0
- package/dist/cjs/sync/sdkNames.d.ts +27 -0
- package/dist/cjs/sync/sdkNames.js +2 -1
- package/dist/esm/backend/index.d.ts +7 -1
- package/dist/esm/runtime/filterConditionTypes.test-d.d.ts +1 -0
- package/dist/esm/runtime/filterConditionTypes.test-d.js +38 -0
- package/dist/esm/runtime/index.d.ts +7 -2
- package/dist/esm/sync/lib.d.ts +28 -1
- package/dist/esm/sync/lib.js +68 -4
- package/dist/esm/sync/lib.test.js +167 -0
- package/dist/esm/sync/sdkNames.d.ts +27 -0
- package/dist/esm/sync/sdkNames.js +1 -1
- 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
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
(0, vitest_1.describe)("FilterCondition in/notIn", () => {
|
|
5
|
+
// The idiom that regressed. `.filter(Boolean)` does not narrow in TypeScript,
|
|
6
|
+
// so this is `(string | undefined)[]` — compiling at all is the assertion.
|
|
7
|
+
(0, vitest_1.it)("accepts .map(…).filter(Boolean) over a nullable column", () => {
|
|
8
|
+
const keys = routes.map((r) => r.compositeRouteKey).filter(Boolean);
|
|
9
|
+
(0, vitest_1.expectTypeOf)(keys).toEqualTypeOf();
|
|
10
|
+
(0, vitest_1.expectTypeOf)().toMatchTypeOf();
|
|
11
|
+
(0, vitest_1.expectTypeOf)().toMatchTypeOf();
|
|
12
|
+
});
|
|
13
|
+
// One array of a union, not a union of arrays.
|
|
14
|
+
(0, vitest_1.it)("collapses a nullable column to a single array type", () => {
|
|
15
|
+
(0, vitest_1.expectTypeOf)().toEqualTypeOf();
|
|
16
|
+
});
|
|
17
|
+
// The unwrapping that the conditional exists for: an array-valued column is
|
|
18
|
+
// filtered by its ELEMENT type, not by arrays of arrays.
|
|
19
|
+
(0, vitest_1.it)("still unwraps an array-valued column to its element type", () => {
|
|
20
|
+
(0, vitest_1.expectTypeOf)().toEqualTypeOf();
|
|
21
|
+
});
|
|
22
|
+
(0, vitest_1.it)("leaves a plain scalar column alone", () => {
|
|
23
|
+
(0, vitest_1.expectTypeOf)().toEqualTypeOf();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
(0, vitest_1.describe)("RecordFilters", () => {
|
|
27
|
+
// Through the keyed wrapper, which is how app code actually reaches it.
|
|
28
|
+
(0, vitest_1.it)("accepts the idiom on a nullable key", () => {
|
|
29
|
+
const filters = {
|
|
30
|
+
compositeRouteKey: {
|
|
31
|
+
in: routes.map((r) => r.compositeRouteKey).filter(Boolean),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
(0, vitest_1.expectTypeOf)(filters).toMatchTypeOf();
|
|
35
|
+
});
|
|
36
|
+
(0, vitest_1.it)("accepts an element-typed `in` on an array column", () => {
|
|
37
|
+
const filters = { tags: { in: ["urgent"] } };
|
|
38
|
+
(0, vitest_1.expectTypeOf)(filters).toMatchTypeOf();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* the operands buys little while breaking real migrated code: dates are ISO
|
|
9
9
|
* strings here but 1.0 accepted `number | Date` on every comparison, and
|
|
10
10
|
* `contains` was `any`.
|
|
11
|
+
*
|
|
12
|
+
* On `in`/`notIn` the `[]` sits OUTSIDE the conditional. A naked `V`
|
|
13
|
+
* distributes, so a nullable column produced `string[] | undefined[]` — two
|
|
14
|
+
* separate array types, neither of which accepts the `(string | undefined)[]`
|
|
15
|
+
* that `.map(…).filter(Boolean)` yields.
|
|
11
16
|
*/
|
|
12
17
|
export type FilterCondition<V> = {
|
|
13
18
|
/** Substring match (text fields) or "has any of these" (linked records). */
|
|
@@ -15,9 +20,9 @@ export type FilterCondition<V> = {
|
|
|
15
20
|
/** Not equal, or — against `null` — "is set". */
|
|
16
21
|
not?: V | null;
|
|
17
22
|
/** An empty array matches NOTHING — the SDK's filters convert to the nested format, where `in: []` is "is one of nothing". */
|
|
18
|
-
in?: V extends Array<infer E> ? E
|
|
23
|
+
in?: (V extends Array<infer E> ? E : V)[];
|
|
19
24
|
/** An empty array applies no filter at all — "is not one of nothing" is always true. */
|
|
20
|
-
notIn?: V extends Array<infer E> ? E
|
|
25
|
+
notIn?: (V extends Array<infer E> ? E : V)[];
|
|
21
26
|
lt?: V | number | Date;
|
|
22
27
|
lte?: V | number | Date;
|
|
23
28
|
gt?: V | number | Date;
|
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;
|
|
@@ -51,7 +52,33 @@ export type AirtableLock = {
|
|
|
51
52
|
integrationId: string;
|
|
52
53
|
tables: AirtableLockTable[];
|
|
53
54
|
};
|
|
54
|
-
|
|
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
|
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;
|
|
@@ -1054,9 +1055,71 @@ function airtableFieldJsdoc(field, table, lock) {
|
|
|
1054
1055
|
return undefined;
|
|
1055
1056
|
return parts.join(". ");
|
|
1056
1057
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from "vitest";
|
|
2
|
+
describe("FilterCondition in/notIn", () => {
|
|
3
|
+
// The idiom that regressed. `.filter(Boolean)` does not narrow in TypeScript,
|
|
4
|
+
// so this is `(string | undefined)[]` — compiling at all is the assertion.
|
|
5
|
+
it("accepts .map(…).filter(Boolean) over a nullable column", () => {
|
|
6
|
+
const keys = routes.map((r) => r.compositeRouteKey).filter(Boolean);
|
|
7
|
+
expectTypeOf(keys).toEqualTypeOf();
|
|
8
|
+
expectTypeOf().toMatchTypeOf();
|
|
9
|
+
expectTypeOf().toMatchTypeOf();
|
|
10
|
+
});
|
|
11
|
+
// One array of a union, not a union of arrays.
|
|
12
|
+
it("collapses a nullable column to a single array type", () => {
|
|
13
|
+
expectTypeOf().toEqualTypeOf();
|
|
14
|
+
});
|
|
15
|
+
// The unwrapping that the conditional exists for: an array-valued column is
|
|
16
|
+
// filtered by its ELEMENT type, not by arrays of arrays.
|
|
17
|
+
it("still unwraps an array-valued column to its element type", () => {
|
|
18
|
+
expectTypeOf().toEqualTypeOf();
|
|
19
|
+
});
|
|
20
|
+
it("leaves a plain scalar column alone", () => {
|
|
21
|
+
expectTypeOf().toEqualTypeOf();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("RecordFilters", () => {
|
|
25
|
+
// Through the keyed wrapper, which is how app code actually reaches it.
|
|
26
|
+
it("accepts the idiom on a nullable key", () => {
|
|
27
|
+
const filters = {
|
|
28
|
+
compositeRouteKey: {
|
|
29
|
+
in: routes.map((r) => r.compositeRouteKey).filter(Boolean),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
expectTypeOf(filters).toMatchTypeOf();
|
|
33
|
+
});
|
|
34
|
+
it("accepts an element-typed `in` on an array column", () => {
|
|
35
|
+
const filters = { tags: { in: ["urgent"] } };
|
|
36
|
+
expectTypeOf(filters).toMatchTypeOf();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* the operands buys little while breaking real migrated code: dates are ISO
|
|
9
9
|
* strings here but 1.0 accepted `number | Date` on every comparison, and
|
|
10
10
|
* `contains` was `any`.
|
|
11
|
+
*
|
|
12
|
+
* On `in`/`notIn` the `[]` sits OUTSIDE the conditional. A naked `V`
|
|
13
|
+
* distributes, so a nullable column produced `string[] | undefined[]` — two
|
|
14
|
+
* separate array types, neither of which accepts the `(string | undefined)[]`
|
|
15
|
+
* that `.map(…).filter(Boolean)` yields.
|
|
11
16
|
*/
|
|
12
17
|
export type FilterCondition<V> = {
|
|
13
18
|
/** Substring match (text fields) or "has any of these" (linked records). */
|
|
@@ -15,9 +20,9 @@ export type FilterCondition<V> = {
|
|
|
15
20
|
/** Not equal, or — against `null` — "is set". */
|
|
16
21
|
not?: V | null;
|
|
17
22
|
/** An empty array matches NOTHING — the SDK's filters convert to the nested format, where `in: []` is "is one of nothing". */
|
|
18
|
-
in?: V extends Array<infer E> ? E
|
|
23
|
+
in?: (V extends Array<infer E> ? E : V)[];
|
|
19
24
|
/** An empty array applies no filter at all — "is not one of nothing" is always true. */
|
|
20
|
-
notIn?: V extends Array<infer E> ? E
|
|
25
|
+
notIn?: (V extends Array<infer E> ? E : V)[];
|
|
21
26
|
lt?: V | number | Date;
|
|
22
27
|
lte?: V | number | Date;
|
|
23
28
|
gt?: V | number | Date;
|
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;
|
|
@@ -51,7 +52,33 @@ export type AirtableLock = {
|
|
|
51
52
|
integrationId: string;
|
|
52
53
|
tables: AirtableLockTable[];
|
|
53
54
|
};
|
|
54
|
-
|
|
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
|
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.
|
|
@@ -1042,9 +1042,71 @@ function airtableFieldJsdoc(field, table, lock) {
|
|
|
1042
1042
|
return undefined;
|
|
1043
1043
|
return parts.join(". ");
|
|
1044
1044
|
}
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
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;
|