zitejs 0.9.105 → 0.9.106

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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Allocation of the identifiers `generateDbTs` emits.
3
+ *
4
+ * Every accessor on the generated `zite` object, and every field key on a
5
+ * record type, has to be unique — and `generateSchema` used to choose them with
6
+ * `toSdkName` and no memory at all. Two consequences, both seen in the wild:
7
+ *
8
+ * - A table named "Notifications" allocated `notifications`, which
9
+ * `generateDbTs` also emits for the platform's notifications client. The
10
+ * sentinel is written last, so it won: the table lost its client entirely
11
+ * and typed as `{ create(params: NotificationsCreateParams) }`.
12
+ * - Two fields whose display names normalize alike both claimed one accessor,
13
+ * and the second silently overwrote the first in the emitted type. No
14
+ * error, no diagnostic — just a column you could no longer read. Zite 1.0's
15
+ * generator de-duplicated with numeric suffixes; 2.0 dropped that.
16
+ *
17
+ * `allocateSchemaNames` is idempotent: a schema whose names are already unique
18
+ * and unreserved allocates to itself. That is what lets `generateDbTs` run it
19
+ * defensively over its input — a schema file written before this existed
20
+ * repairs itself on the next generate instead of emitting TypeScript that
21
+ * cannot compile.
22
+ */
23
+ // The case helpers live here rather than in lib.ts so this module has no import
24
+ // back into it — `lib.ts` needs the allocator, and a cycle between the two is
25
+ // avoidable. `lib.ts` re-exports them, so the published surface is unchanged.
26
+ export function toPascalCase(name) {
27
+ const pascal = name
28
+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
29
+ .replace(/^(.)/, (_, c) => c.toUpperCase())
30
+ // Drop leftover non-alphanumerics (e.g. a trailing ">" from "</p>") so the
31
+ // sdkName is always a valid identifier in .zite/db.ts.
32
+ .replace(/[^a-zA-Z0-9]+/g, "");
33
+ if (!pascal)
34
+ return "_";
35
+ return /^[0-9]/.test(pascal) ? `_${pascal}` : pascal; // can't start with a digit
36
+ }
37
+ export function toCamelCase(name) {
38
+ const pascal = toPascalCase(name);
39
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
40
+ }
41
+ /**
42
+ * camelCase for a FRESH sdkName, reading acronym runs as words: "VIP" ->
43
+ * "vip", "APIKey" -> "apiKey" (plain toCamelCase yields "vIP" / "aPIKey").
44
+ *
45
+ * Deliberately not folded into toCamelCase: that also derives endpoint
46
+ * identifiers from existing filenames on every generate, where normalizing
47
+ * would rename a working app's `api.sendSMS`. This is only for names being
48
+ * chosen for the first time — generateSchema preserves existing sdkNames.
49
+ */
50
+ export function toSdkName(name) {
51
+ const deAcronymed = name
52
+ .replace(/([A-Z]+)([A-Z][a-z])/g, (_, run, next) => run.charAt(0) + run.slice(1).toLowerCase() + next)
53
+ .replace(/([A-Z])([A-Z]+)/g, (_, first, rest) => first + rest.toLowerCase());
54
+ return toCamelCase(deAcronymed);
55
+ }
56
+ /**
57
+ * Accessors `generateDbTs` puts on the `zite` object itself, after the tables.
58
+ * A table that allocates one of these produces a duplicate key.
59
+ */
60
+ export const RESERVED_TABLE_ACCESSORS = [
61
+ "sql",
62
+ "notifications",
63
+ "auth",
64
+ ];
65
+ /**
66
+ * Never safe as a generated key, in any namespace. Assigning `__proto__` in an
67
+ * object literal sets the prototype rather than defining a property, and
68
+ * `constructor` / `prototype` shadow members every object already carries.
69
+ */
70
+ export const RESERVED_IDENTIFIERS = [
71
+ "__proto__",
72
+ "constructor",
73
+ "prototype",
74
+ ];
75
+ /**
76
+ * `id` is emitted by hand on every record type and filtered out of every input
77
+ * type, so a field that allocated it vanished from both. Reserving it means a
78
+ * field genuinely named "ID" gets a usable accessor instead.
79
+ */
80
+ export const RESERVED_FIELD_ACCESSORS = ["id"];
81
+ /** Guards the suffix loop. Far above any real schema; see `allocate`. */
82
+ const MAX_SUFFIX_ATTEMPTS = 10_000;
83
+ /**
84
+ * Unique identifiers within one namespace.
85
+ *
86
+ * `derive` names the extra keys an allocation also occupies. A table needs it:
87
+ * its sdkName becomes both a property on `zite` and, PascalCased, a pair of
88
+ * exported type declarations, so `orders` and `Orders` are distinct accessors
89
+ * that would emit `OrdersRecordType` twice.
90
+ */
91
+ class SdkNameAllocator {
92
+ taken = new Set();
93
+ reserved;
94
+ derive;
95
+ constructor(opts = {}) {
96
+ this.reserved = new Set([
97
+ ...RESERVED_IDENTIFIERS,
98
+ ...(opts.reserved ?? []),
99
+ ]);
100
+ this.derive = opts.derive ?? (() => []);
101
+ }
102
+ keysFor(name) {
103
+ return [name, ...this.derive(name)];
104
+ }
105
+ isFree(name) {
106
+ return this.keysFor(name).every((key) => !this.taken.has(key) && !this.reserved.has(key));
107
+ }
108
+ /** Free right now, without claiming it. */
109
+ available(name) {
110
+ return this.isFree(name);
111
+ }
112
+ claim(name) {
113
+ for (const key of this.keysFor(name))
114
+ this.taken.add(key);
115
+ return name;
116
+ }
117
+ /**
118
+ * `name`, or the first `name2`, `name3`… that is free. Suffixes start at 2
119
+ * because that is what the name means: the second table called Notifications.
120
+ */
121
+ allocate(preferred) {
122
+ const base = preferred || "_";
123
+ if (this.isFree(base))
124
+ return this.claim(base);
125
+ for (let suffix = 2; suffix <= MAX_SUFFIX_ATTEMPTS; suffix++) {
126
+ const candidate = `${base}${suffix}`;
127
+ if (this.isFree(candidate))
128
+ return this.claim(candidate);
129
+ }
130
+ // Unreachable: each attempt blocks at most two keys, so a free candidate
131
+ // exists long before the cap. A generator must never return a duplicate,
132
+ // so say so rather than emitting one.
133
+ throw new Error(`Could not allocate a unique SDK name for "${preferred}" after ${MAX_SUFFIX_ATTEMPTS} attempts`);
134
+ }
135
+ }
136
+ /**
137
+ * Existing sdkNames are preserved across syncs so user code keeps compiling,
138
+ * but a schema written before the sanitizer stripped trailing symbols can carry
139
+ * an invalid identifier (e.g. `timeSeconds)`). Keep one only when it is a valid
140
+ * identifier; otherwise the caller recomputes it from the display name.
141
+ */
142
+ export function keepValidSdkName(sdkName) {
143
+ if (!sdkName)
144
+ return undefined;
145
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(sdkName) ? sdkName : undefined;
146
+ }
147
+ /**
148
+ * Resolve every drafted name to a unique, unreserved one.
149
+ *
150
+ * Two passes per namespace: locked names claim first in declaration order, then
151
+ * fresh ones fill in around them. Without that ordering a new table could take
152
+ * an accessor an existing app already imports.
153
+ */
154
+ export function allocateSchemaNames(drafts) {
155
+ const renames = [];
156
+ const tableNames = new SdkNameAllocator({
157
+ reserved: RESERVED_TABLE_ACCESSORS,
158
+ // The PascalCase form is its own pair of exported declarations.
159
+ derive: (name) => [`type:${toPascalCase(name)}`],
160
+ });
161
+ const resolved = new Map();
162
+ for (const pass of [true, false]) {
163
+ for (const entry of drafts) {
164
+ if (entry.draft.locked !== pass)
165
+ continue;
166
+ if (pass && !tableNames.available(entry.draft.preferred))
167
+ continue;
168
+ const sdkName = tableNames.allocate(entry.draft.preferred);
169
+ resolved.set(entry.table.id, sdkName);
170
+ if (sdkName !== entry.draft.preferred) {
171
+ renames.push({
172
+ kind: "table",
173
+ tableId: entry.table.id,
174
+ from: entry.draft.preferred,
175
+ to: sdkName,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ // A locked name skipped in pass 1 (reserved, or claimed by an earlier locked
181
+ // name) falls through to here and is suffixed like any other.
182
+ for (const entry of drafts) {
183
+ if (resolved.has(entry.table.id))
184
+ continue;
185
+ const sdkName = tableNames.allocate(entry.draft.preferred);
186
+ resolved.set(entry.table.id, sdkName);
187
+ renames.push({
188
+ kind: "table",
189
+ tableId: entry.table.id,
190
+ from: entry.draft.preferred,
191
+ to: sdkName,
192
+ });
193
+ }
194
+ const tables = drafts.map((entry) => {
195
+ const fieldNames = new SdkNameAllocator({
196
+ reserved: RESERVED_FIELD_ACCESSORS,
197
+ });
198
+ const resolvedFields = new Map();
199
+ for (const pass of [true, false]) {
200
+ for (const { field, draft } of entry.fields) {
201
+ if (draft.locked !== pass)
202
+ continue;
203
+ if (pass && !fieldNames.available(draft.preferred))
204
+ continue;
205
+ const sdkName = fieldNames.allocate(draft.preferred);
206
+ resolvedFields.set(field.id, sdkName);
207
+ if (sdkName !== draft.preferred) {
208
+ renames.push({
209
+ kind: "field",
210
+ tableId: entry.table.id,
211
+ fieldId: field.id,
212
+ from: draft.preferred,
213
+ to: sdkName,
214
+ });
215
+ }
216
+ }
217
+ }
218
+ for (const { field, draft } of entry.fields) {
219
+ if (resolvedFields.has(field.id))
220
+ continue;
221
+ const sdkName = fieldNames.allocate(draft.preferred);
222
+ resolvedFields.set(field.id, sdkName);
223
+ renames.push({
224
+ kind: "field",
225
+ tableId: entry.table.id,
226
+ fieldId: field.id,
227
+ from: draft.preferred,
228
+ to: sdkName,
229
+ });
230
+ }
231
+ return {
232
+ ...entry.table,
233
+ sdkName: resolved.get(entry.table.id),
234
+ fields: entry.fields.map(({ field }) => ({
235
+ ...field,
236
+ sdkName: resolvedFields.get(field.id),
237
+ })),
238
+ };
239
+ });
240
+ return { tables, renames };
241
+ }
242
+ /**
243
+ * Repair a schema whose names may already be unique — the idempotent case — or
244
+ * may predate allocation entirely. Everything is treated as locked: these names
245
+ * are what user code compiles against, so only a genuine conflict moves one.
246
+ */
247
+ export function normalizeSchemaNames(schema) {
248
+ const { tables, renames } = allocateSchemaNames(schema.tables.map((table) => ({
249
+ table,
250
+ draft: {
251
+ preferred: keepValidSdkName(table.sdkName) ?? toSdkName(table.sdkName),
252
+ locked: true,
253
+ },
254
+ fields: table.fields.map((field) => ({
255
+ field,
256
+ draft: {
257
+ preferred: keepValidSdkName(field.sdkName) ?? toSdkName(field.sdkName),
258
+ locked: true,
259
+ },
260
+ })),
261
+ })));
262
+ return { schema: { tables }, renames };
263
+ }
264
+ /** One line per rename, for a generator that has no logger of its own. */
265
+ export function describeRenames(renames) {
266
+ return renames.map((r) => r.kind === "table"
267
+ ? `table "${r.from}" -> "${r.to}" (id ${r.tableId})`
268
+ : `field "${r.from}" -> "${r.to}" (table ${r.tableId}, field ${r.fieldId})`);
269
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { generateSchema, generateDbTs } from "./lib.js";
3
+ import { normalizeSchemaNames } from "./sdkNames.js";
4
+ // `generateSchema` warns on every rename; silence it so the suite stays
5
+ // readable, and assert on the emitted names instead.
6
+ beforeEach(() => {
7
+ vi.spyOn(console, "warn").mockImplementation(() => { });
8
+ });
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ });
12
+ const field = (id, name) => ({ id, name, type: "single_line_text", order: 0 });
13
+ const database = (tables) => ({
14
+ id: "base_1",
15
+ name: "Base",
16
+ tables,
17
+ createdAt: "",
18
+ updatedAt: "",
19
+ url: "",
20
+ });
21
+ /** Every key in the emitted `export const zite = { … }` literal. */
22
+ const ziteKeys = (source) => {
23
+ const body = source.slice(source.indexOf("export const zite = {"));
24
+ return [...body.matchAll(/^ {2}([A-Za-z_$][\w$]*):/gm)].map((m) => m[1]);
25
+ };
26
+ /** Every property key on one emitted record type. */
27
+ const recordTypeKeys = (source, typeName) => {
28
+ const start = source.indexOf(`export type ${typeName} = {`);
29
+ const body = source.slice(start, source.indexOf("};", start));
30
+ return [...body.matchAll(/^ {2}([A-Za-z_$][\w$]*)\??:/gm)].map((m) => m[1]);
31
+ };
32
+ describe("reserved platform accessors", () => {
33
+ // Sudzy Dashboard: a table named "Notifications" emitted a second
34
+ // `notifications:` key, and the platform client — written last — won. The
35
+ // table lost findAll and typed as `{ create(NotificationsCreateParams) }`.
36
+ it("moves a table off the notifications accessor", () => {
37
+ const schema = generateSchema(database([{ id: "tbl_1", name: "Notifications", fields: [] }]));
38
+ expect(schema.tables[0].sdkName).toBe("notifications2");
39
+ const keys = ziteKeys(generateDbTs(schema));
40
+ expect(keys).toContain("notifications2");
41
+ expect(keys.filter((k) => k === "notifications")).toHaveLength(1);
42
+ expect(new Set(keys).size).toBe(keys.length);
43
+ });
44
+ it.each(["sql", "auth", "notifications"])("keeps the platform %s accessor for the platform", (reserved) => {
45
+ const schema = generateSchema(database([{ id: "tbl_1", name: reserved, fields: [] }]));
46
+ expect(schema.tables[0].sdkName).toBe(`${reserved}2`);
47
+ const keys = ziteKeys(generateDbTs(schema));
48
+ expect(new Set(keys).size).toBe(keys.length);
49
+ });
50
+ it.each(["__proto__", "constructor", "prototype"])("refuses %s as a table accessor", (hazard) => {
51
+ const schema = generateSchema(database([{ id: "tbl_1", name: hazard, fields: [] }]));
52
+ expect(schema.tables[0].sdkName).not.toBe(hazard);
53
+ });
54
+ });
55
+ describe("collision de-duplication", () => {
56
+ // Nevada Car Coalition: 1.0's generator suffixed colliding names, so the app
57
+ // was written against `category1` / `sponsor1`. 2.0 had no accumulator at
58
+ // all, so the second field silently overwrote the first.
59
+ it("keeps both fields when two display names normalize alike", () => {
60
+ const schema = generateSchema(database([
61
+ {
62
+ id: "tbl_1",
63
+ name: "K9 Winners",
64
+ fields: [field("fld_1", "Category"), field("fld_2", "category")],
65
+ },
66
+ ]));
67
+ const names = schema.tables[0].fields.map((f) => f.sdkName);
68
+ expect(names).toEqual(["category", "category2"]);
69
+ const keys = recordTypeKeys(generateDbTs(schema), "K9WinnersRecordType");
70
+ expect(keys).toEqual(["id", "category", "category2"]);
71
+ });
72
+ it("de-duplicates two tables that normalize alike", () => {
73
+ const schema = generateSchema(database([
74
+ { id: "tbl_1", name: "Orders", fields: [] },
75
+ { id: "tbl_2", name: "orders", fields: [] },
76
+ ]));
77
+ expect(schema.tables.map((t) => t.sdkName)).toEqual(["orders", "orders2"]);
78
+ });
79
+ // Two sdkNames sharing a PascalCase form emit `FooRecordType` twice, which is
80
+ // a duplicate identifier even though the accessors differ.
81
+ it("separates tables whose class names would collide", () => {
82
+ const source = generateDbTs({
83
+ tables: [
84
+ { id: "tbl_1", sdkName: "orders", fields: [] },
85
+ { id: "tbl_2", sdkName: "Orders", fields: [] },
86
+ ],
87
+ });
88
+ const declared = [...source.matchAll(/export type (\w+RecordType)/g)].map((m) => m[1]);
89
+ expect(new Set(declared).size).toBe(declared.length);
90
+ });
91
+ it("reserves id so a field named ID stays readable", () => {
92
+ const schema = generateSchema(database([{ id: "tbl_1", name: "Rows", fields: [field("fld_1", "ID")] }]));
93
+ expect(schema.tables[0].fields[0].sdkName).toBe("id2");
94
+ expect(recordTypeKeys(generateDbTs(schema), "RowsRecordType")).toEqual([
95
+ "id",
96
+ "id2",
97
+ ]);
98
+ });
99
+ });
100
+ describe("name stability", () => {
101
+ const existing = {
102
+ tables: [
103
+ {
104
+ id: "tbl_1",
105
+ sdkName: "priceList",
106
+ fields: [
107
+ { id: "fld_1", sdkName: "srp", definition: { type: "number" } },
108
+ ],
109
+ },
110
+ ],
111
+ };
112
+ // Kelly: the app was written against `srp`, and the field's display name has
113
+ // drifted since. A locked name is what the app compiles against.
114
+ it("preserves a locked name over the one the display name would produce", () => {
115
+ const schema = generateSchema(database([
116
+ {
117
+ id: "tbl_1",
118
+ name: "Price List",
119
+ fields: [field("fld_1", "SRP (Suggested Retail Price)")],
120
+ },
121
+ ]), existing);
122
+ expect(schema.tables[0].fields[0].sdkName).toBe("srp");
123
+ });
124
+ it("does not let a new field displace a locked one", () => {
125
+ const schema = generateSchema(database([
126
+ {
127
+ id: "tbl_1",
128
+ name: "Price List",
129
+ // The new field's fresh name is exactly what fld_1 holds.
130
+ fields: [field("fld_2", "SRP"), field("fld_1", "Anything")],
131
+ },
132
+ ]), existing);
133
+ const byId = Object.fromEntries(schema.tables[0].fields.map((f) => [f.id, f.sdkName]));
134
+ expect(byId.fld_1).toBe("srp");
135
+ expect(byId.fld_2).toBe("srp2");
136
+ });
137
+ it("is stable across repeated generation", () => {
138
+ const db = database([
139
+ {
140
+ id: "tbl_1",
141
+ name: "Notifications",
142
+ fields: [field("fld_1", "Category"), field("fld_2", "category")],
143
+ },
144
+ ]);
145
+ const first = generateSchema(db);
146
+ const second = generateSchema(db, first);
147
+ const third = generateSchema(db, second);
148
+ expect(second).toEqual(first);
149
+ expect(third).toEqual(first);
150
+ });
151
+ it("recomputes a name that is not a valid identifier", () => {
152
+ const schema = generateSchema(database([
153
+ { id: "tbl_1", name: "Rows", fields: [field("fld_1", "Time")] },
154
+ ]), {
155
+ tables: [
156
+ {
157
+ id: "tbl_1",
158
+ sdkName: "rows",
159
+ fields: [
160
+ {
161
+ id: "fld_1",
162
+ sdkName: "timeSeconds)",
163
+ definition: { type: "number" },
164
+ },
165
+ ],
166
+ },
167
+ ],
168
+ });
169
+ expect(schema.tables[0].fields[0].sdkName).toBe("time");
170
+ });
171
+ });
172
+ describe("normalizeSchemaNames", () => {
173
+ it("is a no-op on a well-formed schema", () => {
174
+ const schema = {
175
+ tables: [
176
+ {
177
+ id: "tbl_1",
178
+ sdkName: "orders",
179
+ fields: [
180
+ { id: "fld_1", sdkName: "total", definition: { type: "number" } },
181
+ ],
182
+ },
183
+ ],
184
+ };
185
+ const result = normalizeSchemaNames(schema);
186
+ expect(result.renames).toEqual([]);
187
+ expect(result.schema).toEqual(schema);
188
+ });
189
+ // A schema committed before allocation existed. The sandbox's boot-time
190
+ // `zitejs generate` reads it with no generateSchema pass in front, so this is
191
+ // the only thing standing between it and a TS1117 it cannot build past.
192
+ it("repairs a committed schema that already holds the collision", () => {
193
+ const source = generateDbTs({
194
+ tables: [
195
+ { id: "tbl_1", sdkName: "notifications", fields: [] },
196
+ { id: "tbl_2", sdkName: "orders", fields: [] },
197
+ ],
198
+ });
199
+ const keys = ziteKeys(source);
200
+ expect(new Set(keys).size).toBe(keys.length);
201
+ expect(keys).toContain("notifications2");
202
+ });
203
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.105",
3
+ "version": "0.9.106",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",