zitejs 0.9.107 → 0.9.109

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.
@@ -12,9 +12,18 @@ function findAppDirs() {
12
12
  .filter(d => d.isDirectory())
13
13
  .map(d => d.name);
14
14
  }
15
- function run(cmd, cwd) {
15
+ /**
16
+ * An argument array, not a command string, so no shell is involved.
17
+ *
18
+ * Every command below takes an app directory name straight off `readdirSync`.
19
+ * Through a shell, a directory named `x;touch PWNED;#` ran the injected command
20
+ * AND still printed `tsc --noEmit ... ✓` — a check that never executed
21
+ * reporting a pass, which is the worse half. Passing argv defeats both, and
22
+ * leaves nothing to escape.
23
+ */
24
+ function run(file, args, cwd) {
16
25
  try {
17
- const output = (0, child_process_1.execSync)(cmd, {
26
+ const output = (0, child_process_1.execFileSync)(file, args, {
18
27
  cwd,
19
28
  encoding: 'utf-8',
20
29
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -78,7 +87,7 @@ async function runCheck() {
78
87
  }
79
88
  else {
80
89
  process.stdout.write(' tsc --noEmit ... ');
81
- const tsc = run(`npx tsc --noEmit -p ${tsconfigAppPath}`, '.');
90
+ const tsc = run('npx', ['tsc', '--noEmit', '-p', tsconfigAppPath], '.');
82
91
  if (tsc.ok) {
83
92
  console.log('✓');
84
93
  }
@@ -93,7 +102,7 @@ async function runCheck() {
93
102
  // `zitejs/*` alias has no generated file behind it; only the bundler knows.
94
103
  if ((0, fs_1.existsSync)((0, path_1.join)(appPath, 'src', 'api'))) {
95
104
  process.stdout.write(' bundle endpoints ... ');
96
- const bundle = run(`npx zitejs bundle --app ${app}`, '.');
105
+ const bundle = run('npx', ['zitejs', 'bundle', '--app', app], '.');
97
106
  const failures = bundle.ok ? bundleFailures(bundle.output) : [bundle.output];
98
107
  if (failures.length === 0) {
99
108
  console.log('✓');
@@ -107,7 +116,7 @@ async function runCheck() {
107
116
  const viteConfig = (0, path_1.join)(appPath, 'vite.config.ts');
108
117
  if ((0, fs_1.existsSync)(viteConfig)) {
109
118
  process.stdout.write(' vite build ... ');
110
- const vite = run('npx vite build', appPath);
119
+ const vite = run('npx', ['vite', 'build'], appPath);
111
120
  if (vite.ok) {
112
121
  console.log('✓');
113
122
  }
@@ -1,4 +1,3 @@
1
- import type { NotificationsCreateParams, NotificationsCreateResult } from "../notifications/index.js";
2
1
  /**
3
2
  * A comparison against one field. Mirrors base-runner's operator set
4
3
  * (`LegacyWhereConditionOperators`); anything else in the object is ignored.
@@ -121,8 +120,15 @@ export interface TableFindAllOptions<T = Record<string, unknown>> {
121
120
  export interface BulkCreateResult<T> {
122
121
  /** Absent when `records: []` was passed — the dispatch short-circuits before setting it. */
123
122
  success?: boolean;
123
+ /**
124
+ * The nested copy has no `id`: both dispatchers build it with
125
+ * `const { id, ...fields } = record`, so `fields` is the record *minus* the
126
+ * id. Typing it `T` (which requires `id`) made the common
127
+ * `{ id: rec.id, ...rec.fields }` idiom a TS2783 — the type promised the
128
+ * spread always overwrites `id` when at runtime it never does.
129
+ */
124
130
  records: Array<T & {
125
- fields: T;
131
+ fields: Omit<T, "id">;
126
132
  }>;
127
133
  }
128
134
  /** A type alias, not an interface: an interface cannot extend a generic `Partial<T>`. */
@@ -321,9 +327,6 @@ export interface EmailClient {
321
327
  * app's connected email integration key.
322
328
  */
323
329
  export declare function createEmailClient(integrationId: string): EmailClient;
324
- export declare function createNotificationsClient(): {
325
- create(params: NotificationsCreateParams): Promise<NotificationsCreateResult>;
326
- };
327
330
  export type { NotificationLink, NotificationsCreateParams, NotificationsCreateResult, } from "../notifications/index.js";
328
331
  export { createCaller } from "../caller/index.js";
329
332
  export type { EndpointConfig } from "../caller/index.js";
@@ -6,7 +6,6 @@ exports.createSqlClient = createSqlClient;
6
6
  exports.createAuthClient = createAuthClient;
7
7
  exports.createAirtableClient = createAirtableClient;
8
8
  exports.createEmailClient = createEmailClient;
9
- exports.createNotificationsClient = createNotificationsClient;
10
9
  const sdkCall_js_1 = require("../internal/sdkCall.js");
11
10
  const DB_INTEGRATION_ID = "databases";
12
11
  function getBaseId() {
@@ -126,11 +125,5 @@ function createEmailClient(integrationId) {
126
125
  send: (params) => (0, sdkCall_js_1.getSdkCall)()(integrationId, "Email", "send", params),
127
126
  };
128
127
  }
129
- const NOTIFICATIONS_SDK_INTEGRATION_ID = "__notifications__";
130
- function createNotificationsClient() {
131
- return {
132
- create: (params) => (0, sdkCall_js_1.getSdkCall)()(NOTIFICATIONS_SDK_INTEGRATION_ID, "ZiteNotifications", "create", params),
133
- };
134
- }
135
128
  var index_js_1 = require("../caller/index.js");
136
129
  Object.defineProperty(exports, "createCaller", { enumerable: true, get: function () { return index_js_1.createCaller; } });
@@ -13,6 +13,17 @@ const vitest_1 = require("vitest");
13
13
  (0, vitest_1.expectTypeOf)().toEqualTypeOf();
14
14
  (0, vitest_1.expectTypeOf)().toEqualTypeOf();
15
15
  });
16
+ // Both dispatchers destructure `id` out before nesting
17
+ // (`const { id, ...fields } = record`), so `fields` must not claim one.
18
+ (0, vitest_1.it)("omits id from the nested copy", () => {
19
+ (0, vitest_1.expectTypeOf)().toEqualTypeOf();
20
+ });
21
+ // The idiom that regressed under `fields: T` (TS2783, "this spread always
22
+ // overwrites this property"). Compiling at all is the assertion.
23
+ (0, vitest_1.it)("allows the id-then-spread idiom", () => {
24
+ const rebuild = (rec) => ({ id: rec.id, ...rec.fields });
25
+ (0, vitest_1.expectTypeOf)().toEqualTypeOf();
26
+ });
16
27
  (0, vitest_1.it)("keeps success optional", () => {
17
28
  (0, vitest_1.expectTypeOf)().toEqualTypeOf();
18
29
  });
@@ -150,6 +150,31 @@ const ATTACHMENT_TYPES = [
150
150
  "",
151
151
  ];
152
152
  const MAX_SELECT_OPTIONS = 100;
153
+ /**
154
+ * A select option label as a TypeScript string-literal type.
155
+ *
156
+ * `JSON.stringify` rather than hand-rolled quote escaping: a label is arbitrary
157
+ * user text, and a newline in one produced an unterminated literal that made the
158
+ * whole of `.zite/db.ts` unparseable — which breaks typecheck for every app in
159
+ * the project, not just the one that owns the table. Backslashes and control
160
+ * characters break it the same way. TS literal syntax is a superset of JSON's
161
+ * for strings, so the output is always valid.
162
+ */
163
+ const tsStringLiteral = (value) => JSON.stringify(value);
164
+ /**
165
+ * The same, single-quoted, for the emitters that write single-quoted source.
166
+ * The escaping still comes from `JSON.stringify` — only the delimiter differs —
167
+ * so newlines, backslashes and control characters stay handled.
168
+ */
169
+ const tsSingleQuoted = (value) => `'${JSON.stringify(value).slice(1, -1).replace(/'/g, "\\'")}'`;
170
+ /**
171
+ * User-controlled text inside a generated comment. Names reach both a `//` line
172
+ * and a one-line JSDoc, so a newline would end the first and a comment-close
173
+ * sequence would end the second — leaving the remainder to parse as code.
174
+ * Applied where the comment is written rather than at each contributor, so
175
+ * anything added to one later is covered by default.
176
+ */
177
+ const commentSafe = (value) => value.replace(/\*\//g, "* /").replace(/\s+/g, " ").trim();
153
178
  const NUMBER_FORMAT_EXAMPLES = {
154
179
  local: "1,000,000.50",
155
180
  comma_period: "1,000,000.50",
@@ -200,7 +225,7 @@ function tsTypeForSchemaField(def, variant = "read") {
200
225
  if (options && options.length > 0) {
201
226
  const literals = options
202
227
  .slice(0, MAX_SELECT_OPTIONS)
203
- .map((o) => `"${o.label.replace(/"/g, '\\"')}"`)
228
+ .map((o) => tsStringLiteral(o.label))
204
229
  .join(" | ");
205
230
  const union = `${literals} | string`;
206
231
  // No `| null` on the read side — see FIELD_TYPE_MAP. The write side keeps
@@ -454,7 +479,7 @@ function buildLinkTableComments(schema) {
454
479
  "// Link tables for zite.sql() JOINs (use these exact names):",
455
480
  ];
456
481
  for (const e of entries) {
457
- lines.push(`// "${e.name}" — columns: "${e.cols[0]}", "${e.cols[1]}"`);
482
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
458
483
  }
459
484
  return lines;
460
485
  }
@@ -535,7 +560,7 @@ function generateDbTs(inputSchema) {
535
560
  lines.push("// - SELECT only — use .create/.update/.delete for writes");
536
561
  lines.push('// - Load the "zite:sql" skill for full SQL reference (formulas, lookups, etc.)');
537
562
  lines.push(...buildLinkTableComments(schema));
538
- lines.push("import { createTableClient, createSqlClient, createNotificationsClient, createAuthClient } from 'zitejs/runtime';");
563
+ lines.push("import { createTableClient, createSqlClient, createAuthClient } from 'zitejs/runtime';");
539
564
  lines.push("");
540
565
  lines.push(...ATTACHMENT_TYPES);
541
566
  for (const table of tables) {
@@ -550,7 +575,7 @@ function generateDbTs(inputSchema) {
550
575
  const tsType = tsTypeForSchemaField(field.definition);
551
576
  const jsdoc = fieldJsdoc(field, table, schema);
552
577
  if (jsdoc) {
553
- lines.push(` /** ${jsdoc} */`);
578
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
554
579
  }
555
580
  // Optional, matching the pre-monorepo type (`required: ['id']` — "none of
556
581
  // these are required to be defined aside from 'id'"). A `fields:`
@@ -580,7 +605,10 @@ function generateDbTs(inputSchema) {
580
605
  lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
581
606
  }
582
607
  lines.push(` sql: createSqlClient(),`);
583
- lines.push(` notifications: createNotificationsClient(),`);
608
+ // No `notifications` here: it is a platform primitive, not something backed
609
+ // by the project database, so it lives at `zitejs/notifications` alongside
610
+ // `zitejs/pdf` and `zitejs/schedules`. Keeping it on `zite` cost a table
611
+ // named "Notifications" its accessor — see RESERVED_TABLE_ACCESSORS.
584
612
  lines.push(` auth: createAuthClient<ZiteAuthUser>(),`);
585
613
  lines.push("};");
586
614
  lines.push("");
@@ -768,13 +796,13 @@ function generateApiTs(endpointFiles) {
768
796
  if (skipped.length > 0) {
769
797
  lines.push("// Not endpoints, so no callers were generated for them:");
770
798
  for (const name of skipped)
771
- lines.push(`// ${name}`);
799
+ lines.push(`// ${commentSafe(name)}`);
772
800
  lines.push("");
773
801
  }
774
802
  for (const { pascal, baseName, typed } of endpoints) {
775
803
  if (!typed)
776
804
  continue;
777
- lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
805
+ lines.push(`import type { default as _${pascal}Ep } from ${tsSingleQuoted(`../src/api/${baseName}`)};`);
778
806
  }
779
807
  lines.push("");
780
808
  for (const { baseName, ident, pascal, stream, typed } of endpoints) {
@@ -782,10 +810,10 @@ function generateApiTs(endpointFiles) {
782
810
  // Declared with `export const x = createEndpoint(...)` — the 1.0 shape.
783
811
  // The route is real and the bundler deploys it, so the caller has to
784
812
  // exist; there is just no default export to read its types from.
785
- lines.push(`// '${baseName}' has no default export, so its input/output are untyped.`);
813
+ lines.push(`// ${commentSafe(`'${baseName}'`)} has no default export, so its input/output are untyped.`);
786
814
  lines.push(`export type ${pascal}InputType = unknown;`);
787
815
  lines.push(`export type ${pascal}OutputType = unknown;`);
788
- lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
816
+ lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>(${tsSingleQuoted(baseName)});`);
789
817
  lines.push("");
790
818
  continue;
791
819
  }
@@ -804,7 +832,7 @@ function generateApiTs(endpointFiles) {
804
832
  // Calling it `sendEmail` here made every hyphenated or snake_cased endpoint
805
833
  // a 404.
806
834
  const caller = stream ? "createStreamingCaller" : "createCaller";
807
- lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
835
+ lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>(${tsSingleQuoted(baseName)});`);
808
836
  lines.push("");
809
837
  }
810
838
  lines.push("");
@@ -874,7 +902,14 @@ const AIRTABLE_FIELD_INPUT_TYPE_MAP = {
874
902
  singleCollaborator: "{ id: string } | { email: string }",
875
903
  multipleCollaborators: "Array<{ id: string } | { email: string }>",
876
904
  };
877
- /** Mirrors `airtable/lib/attachment.d.ts`. Every property but `thumbnails` is present on a read. */
905
+ /**
906
+ * Airtable's attachment object as the REST API really returns it.
907
+ *
908
+ * Not quite `airtable/lib/attachment.d.ts`: that declaration omits `width` and
909
+ * `height`, which the API does return for image attachments. 1.0 exposed them
910
+ * and apps read them, so leaving them out turns a working `photo.width` into a
911
+ * migration type error. Optional, because a non-image attachment has neither.
912
+ */
878
913
  const AIRTABLE_ATTACHMENT_TYPE = [
879
914
  "export type AirtableAttachment = {",
880
915
  " id: string;",
@@ -884,6 +919,10 @@ const AIRTABLE_ATTACHMENT_TYPE = [
884
919
  " size: number;",
885
920
  " /** MIME type. */",
886
921
  " type: string;",
922
+ " /** Pixel width — images only. */",
923
+ " width?: number;",
924
+ " /** Pixel height — images only. */",
925
+ " height?: number;",
887
926
  " thumbnails?: {",
888
927
  " small: { url: string; width: number; height: number };",
889
928
  " large: { url: string; width: number; height: number };",
@@ -912,7 +951,7 @@ function airtableTsType(field, lock, depth, variant = "read") {
912
951
  choices.length > 0) {
913
952
  const literals = choices
914
953
  .slice(0, MAX_SELECT_OPTIONS)
915
- .map((o) => `"${o.replace(/"/g, '\\"')}"`)
954
+ .map((o) => tsStringLiteral(o))
916
955
  .join(" | ");
917
956
  const union = `${literals} | string`;
918
957
  if (field.type === "multipleSelects")
@@ -1058,7 +1097,7 @@ function generateAirtableTs(lock) {
1058
1097
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1059
1098
  for (const table of lock.tables) {
1060
1099
  const recordType = `${table.sdkName}RecordType`;
1061
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
1100
+ lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1062
1101
  lines.push(`export type ${recordType} = {`);
1063
1102
  lines.push(" id: string;");
1064
1103
  for (const field of table.fields) {
@@ -1066,7 +1105,7 @@ function generateAirtableTs(lock) {
1066
1105
  continue;
1067
1106
  const jsdoc = airtableFieldJsdoc(field, table, lock);
1068
1107
  if (jsdoc) {
1069
- lines.push(` /** ${jsdoc} */`);
1108
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
1070
1109
  }
1071
1110
  const tsType = airtableTsType(field, lock);
1072
1111
  // Optional, because Airtable omits a field from the response entirely
@@ -1080,7 +1119,7 @@ function generateAirtableTs(lock) {
1080
1119
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1081
1120
  // to a formula, rollup, lookup or autonumber with a 422.
1082
1121
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
1083
- lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
1122
+ lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1084
1123
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1085
1124
  for (const field of writableFields) {
1086
1125
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1246,7 +1285,7 @@ function generateEmailSdk(integrationId) {
1246
1285
  " SendEmailResult,",
1247
1286
  "} from 'zitejs/runtime';",
1248
1287
  "",
1249
- `export const Email = createEmailClient('${integrationId}');`,
1288
+ `export const Email = createEmailClient(${tsSingleQuoted(integrationId)});`,
1250
1289
  "",
1251
1290
  ].join("\n");
1252
1291
  }
@@ -1,7 +1,161 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  const vitest_1 = require("vitest");
7
+ const typescript_1 = __importDefault(require("typescript"));
4
8
  const lib_js_1 = require("./lib.js");
9
+ /**
10
+ * Syntax errors in the emitted source. `.zite/db.ts` sits at the repo root and
11
+ * every app imports it, so anything unparseable here fails typecheck for the
12
+ * whole project — and the file is generated, so no one can edit their way out.
13
+ */
14
+ const syntaxErrorsIn = (source) => (typescript_1.default.transpileModule(source, {
15
+ reportDiagnostics: true,
16
+ compilerOptions: { target: typescript_1.default.ScriptTarget.Latest },
17
+ }).diagnostics ?? []).map(d => typescript_1.default.flattenDiagnosticMessageText(d.messageText, ' '));
18
+ const schemaWithSelectOption = (label) => ({
19
+ tables: [
20
+ {
21
+ id: 'tbl1',
22
+ sdkName: 'registrations',
23
+ fields: [
24
+ {
25
+ id: 'fld1',
26
+ sdkName: 'ticketType',
27
+ definition: {
28
+ type: 'single_select',
29
+ template: { options: [{ label }] },
30
+ },
31
+ },
32
+ ],
33
+ },
34
+ ],
35
+ });
36
+ (0, vitest_1.describe)('generateDbTs select option literals', () => {
37
+ // Reported from a migrated app: a consent paragraph pasted in as an option
38
+ // label carried a newline, so the emitted literal never closed and the whole
39
+ // generated file failed with TS1002 — taking every sibling app with it.
40
+ vitest_1.it.each([
41
+ ['a newline', 'I am 18 or above.\nI agree to the rules.'],
42
+ ['a carriage return', 'Lite Pass\r\nRegular Pass'],
43
+ ['a double quote', 'The "Premium" tier'],
44
+ ['a trailing backslash', 'Group / Crew Purchase\\'],
45
+ ['a tab', 'Lite\tPass'],
46
+ ])('emits parseable TypeScript for a label containing %s', (_what, label) => {
47
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateDbTs)(schemaWithSelectOption(label)))).toEqual([]);
48
+ });
49
+ (0, vitest_1.it)('keeps the option readable in the union', () => {
50
+ const out = (0, lib_js_1.generateDbTs)(schemaWithSelectOption('The "Premium" tier'));
51
+ (0, vitest_1.expect)(out).toContain('"The \\"Premium\\" tier"');
52
+ });
53
+ });
54
+ (0, vitest_1.describe)('generateAirtableTs attachment type', () => {
55
+ const lockWithAttachment = {
56
+ integrationId: 'airtable',
57
+ tables: [
58
+ {
59
+ id: 'tbl1',
60
+ sdkName: 'Speakers',
61
+ primaryFieldId: 'fld1',
62
+ fields: [
63
+ { id: 'fld1', sdkName: 'name', type: 'singleLineText', name: 'Name' },
64
+ {
65
+ id: 'fld2',
66
+ sdkName: 'headshot',
67
+ type: 'multipleAttachments',
68
+ name: 'Headshot',
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ };
74
+ // Reported from a migrated app reading `photo.width` to lay out an image
75
+ // grid. Airtable returns width/height on image attachments and 1.0 exposed
76
+ // them; `airtable/lib/attachment.d.ts`, which this type was copied from,
77
+ // omits both — so the read became a TS2339 the app could not edit away.
78
+ vitest_1.it.each(['width', 'height'])('exposes %s, which Airtable returns for images', prop => {
79
+ const out = (0, lib_js_1.generateAirtableTs)(lockWithAttachment);
80
+ (0, vitest_1.expect)(out).not.toBeNull();
81
+ (0, vitest_1.expect)(out).toContain(` ${prop}?: number;`);
82
+ });
83
+ (0, vitest_1.it)('emits parseable TypeScript', () => {
84
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateAirtableTs)(lockWithAttachment) ?? '')).toEqual([]);
85
+ });
86
+ });
87
+ (0, vitest_1.describe)('generateDbTs field names in comments', () => {
88
+ // A field's display name is user text and reaches a one-line JSDoc. A
89
+ // comment-close sequence in it would end the comment early and leave the
90
+ // remainder to parse as code.
91
+ const schemaWithFieldNamed = (name) => ({
92
+ tables: [
93
+ {
94
+ id: 'tbl1',
95
+ sdkName: 'orders',
96
+ primaryFieldId: 'fld1',
97
+ fields: [
98
+ {
99
+ id: 'fld1',
100
+ sdkName: 'amount',
101
+ definition: {
102
+ type: 'currency',
103
+ name,
104
+ template: { currencySymbol: '$' },
105
+ },
106
+ },
107
+ ],
108
+ },
109
+ ],
110
+ });
111
+ vitest_1.it.each([
112
+ ['a comment-close sequence', 'Total */ console.log(1); /*'],
113
+ ['a newline', 'Total\namount'],
114
+ ])('emits parseable TypeScript for a field named with %s', (_what, name) => {
115
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateDbTs)(schemaWithFieldNamed(name)))).toEqual([]);
116
+ });
117
+ });
118
+ (0, vitest_1.describe)('generateApiTs endpoint file names', () => {
119
+ // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
120
+ // validates its characters — and it lands in an import specifier, a comment
121
+ // and two string literals. `.zite/api.ts` is imported by every component that
122
+ // calls `api.*`, so one bad name takes the whole app down.
123
+ vitest_1.it.each([
124
+ ['an apostrophe', "it's.ts"],
125
+ ['a newline', 'ok\nreport.ts'],
126
+ ['a trailing backslash', 'back\\.ts'],
127
+ ['a double quote', 'say"hi.ts'],
128
+ ])('emits parseable TypeScript for a file named with %s', (_what, name) => {
129
+ const out = (0, lib_js_1.generateApiTs)([
130
+ { fileName: name, content: 'export default createEndpoint({});' },
131
+ ]);
132
+ (0, vitest_1.expect)(out).not.toBeNull();
133
+ (0, vitest_1.expect)(syntaxErrorsIn(out)).toEqual([]);
134
+ });
135
+ // The no-default-export branch puts the name in a `//` comment instead, so it
136
+ // needs its own case: there a line terminator ends the comment, not a string.
137
+ (0, vitest_1.it)('emits parseable TypeScript for an untyped endpoint with a newline', () => {
138
+ const out = (0, lib_js_1.generateApiTs)([
139
+ {
140
+ fileName: 'ok\nreport.ts',
141
+ content: 'export const report = createEndpoint({});',
142
+ },
143
+ ]);
144
+ (0, vitest_1.expect)(out).not.toBeNull();
145
+ (0, vitest_1.expect)(syntaxErrorsIn(out)).toEqual([]);
146
+ });
147
+ });
148
+ (0, vitest_1.describe)('generateEmailSdk integration id', () => {
149
+ // An LLM-authored `zite.config.json` key, validated only as
150
+ // `z.record(z.string(), …)`.
151
+ vitest_1.it.each([
152
+ ['an apostrophe', "resend'prod"],
153
+ ['a newline', 'resend\nprod'],
154
+ ['a trailing backslash', 'resend\\'],
155
+ ])('emits parseable TypeScript for an id with %s', (_what, id) => {
156
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateEmailSdk)(id))).toEqual([]);
157
+ });
158
+ });
5
159
  (0, vitest_1.describe)('generateBackendWrapperTs', () => {
6
160
  const output = (0, lib_js_1.generateBackendWrapperTs)();
7
161
  (0, vitest_1.it)('imports User from zitejs/auth', () => {
@@ -36,8 +36,15 @@ export declare function toSdkName(name: string): string;
36
36
  /**
37
37
  * Accessors `generateDbTs` puts on the `zite` object itself, after the tables.
38
38
  * A table that allocates one of these produces a duplicate key.
39
+ *
40
+ * `notifications` used to be here and no longer is: it was a platform
41
+ * primitive hanging off `zite`, so a table named "Notifications" collided with
42
+ * it and — because the sentinel was written last — lost its client entirely.
43
+ * Moving it to `zitejs/notifications` removes the collision rather than
44
+ * managing it, and frees the name for the table that wants it. Everything left
45
+ * is genuinely backed by the project database.
39
46
  */
40
- export declare const RESERVED_TABLE_ACCESSORS: readonly ["sql", "notifications", "auth"];
47
+ export declare const RESERVED_TABLE_ACCESSORS: readonly ["sql", "auth"];
41
48
  /**
42
49
  * Never safe as a generated key, in any namespace. Assigning `__proto__` in an
43
50
  * object literal sets the prototype rather than defining a property, and
@@ -66,12 +66,15 @@ function toSdkName(name) {
66
66
  /**
67
67
  * Accessors `generateDbTs` puts on the `zite` object itself, after the tables.
68
68
  * A table that allocates one of these produces a duplicate key.
69
+ *
70
+ * `notifications` used to be here and no longer is: it was a platform
71
+ * primitive hanging off `zite`, so a table named "Notifications" collided with
72
+ * it and — because the sentinel was written last — lost its client entirely.
73
+ * Moving it to `zitejs/notifications` removes the collision rather than
74
+ * managing it, and frees the name for the table that wants it. Everything left
75
+ * is genuinely backed by the project database.
69
76
  */
70
- exports.RESERVED_TABLE_ACCESSORS = [
71
- "sql",
72
- "notifications",
73
- "auth",
74
- ];
77
+ exports.RESERVED_TABLE_ACCESSORS = ["sql", "auth"];
75
78
  /**
76
79
  * Never safe as a generated key, in any namespace. Assigning `__proto__` in an
77
80
  * object literal sets the prototype rather than defining a property, and
@@ -33,17 +33,18 @@ const recordTypeKeys = (source, typeName) => {
33
33
  };
34
34
  (0, vitest_1.describe)("reserved platform accessors", () => {
35
35
  // Sudzy Dashboard: a table named "Notifications" emitted a second
36
- // `notifications:` key, and the platform client — written last — won. The
36
+ // `notifications:` key and the platform client — written last — won, so the
37
37
  // table lost findAll and typed as `{ create(NotificationsCreateParams) }`.
38
- (0, vitest_1.it)("moves a table off the notifications accessor", () => {
38
+ // The platform client has since moved to `zitejs/notifications`, so the name
39
+ // belongs to the table again and there is nothing left to collide with.
40
+ (0, vitest_1.it)("lets a table keep the notifications accessor", () => {
39
41
  const schema = (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: "Notifications", fields: [] }]));
40
- (0, vitest_1.expect)(schema.tables[0].sdkName).toBe("notifications2");
42
+ (0, vitest_1.expect)(schema.tables[0].sdkName).toBe("notifications");
41
43
  const keys = ziteKeys((0, lib_js_1.generateDbTs)(schema));
42
- (0, vitest_1.expect)(keys).toContain("notifications2");
43
44
  (0, vitest_1.expect)(keys.filter((k) => k === "notifications")).toHaveLength(1);
44
45
  (0, vitest_1.expect)(new Set(keys).size).toBe(keys.length);
45
46
  });
46
- vitest_1.it.each(["sql", "auth", "notifications"])("keeps the platform %s accessor for the platform", (reserved) => {
47
+ vitest_1.it.each(["sql", "auth"])("keeps the platform %s accessor for the platform", (reserved) => {
47
48
  const schema = (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: reserved, fields: [] }]));
48
49
  (0, vitest_1.expect)(schema.tables[0].sdkName).toBe(`${reserved}2`);
49
50
  const keys = ziteKeys((0, lib_js_1.generateDbTs)(schema));
@@ -192,14 +193,17 @@ const recordTypeKeys = (source, typeName) => {
192
193
  // `zitejs generate` reads it with no generateSchema pass in front, so this is
193
194
  // the only thing standing between it and a TS1117 it cannot build past.
194
195
  (0, vitest_1.it)("repairs a committed schema that already holds the collision", () => {
196
+ // `sql`, not `notifications` — the latter stopped being a platform
197
+ // accessor when the client moved to `zitejs/notifications`, so a schema
198
+ // holding it is no longer a collision to repair.
195
199
  const source = (0, lib_js_1.generateDbTs)({
196
200
  tables: [
197
- { id: "tbl_1", sdkName: "notifications", fields: [] },
201
+ { id: "tbl_1", sdkName: "sql", fields: [] },
198
202
  { id: "tbl_2", sdkName: "orders", fields: [] },
199
203
  ],
200
204
  });
201
205
  const keys = ziteKeys(source);
202
206
  (0, vitest_1.expect)(new Set(keys).size).toBe(keys.length);
203
- (0, vitest_1.expect)(keys).toContain("notifications2");
207
+ (0, vitest_1.expect)(keys).toContain("sql2");
204
208
  });
205
209
  });
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  import { existsSync, readdirSync } from 'fs';
3
3
  import { join } from 'path';
4
4
  function findAppDirs() {
@@ -9,9 +9,18 @@ function findAppDirs() {
9
9
  .filter(d => d.isDirectory())
10
10
  .map(d => d.name);
11
11
  }
12
- function run(cmd, cwd) {
12
+ /**
13
+ * An argument array, not a command string, so no shell is involved.
14
+ *
15
+ * Every command below takes an app directory name straight off `readdirSync`.
16
+ * Through a shell, a directory named `x;touch PWNED;#` ran the injected command
17
+ * AND still printed `tsc --noEmit ... ✓` — a check that never executed
18
+ * reporting a pass, which is the worse half. Passing argv defeats both, and
19
+ * leaves nothing to escape.
20
+ */
21
+ function run(file, args, cwd) {
13
22
  try {
14
- const output = execSync(cmd, {
23
+ const output = execFileSync(file, args, {
15
24
  cwd,
16
25
  encoding: 'utf-8',
17
26
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -75,7 +84,7 @@ export async function runCheck() {
75
84
  }
76
85
  else {
77
86
  process.stdout.write(' tsc --noEmit ... ');
78
- const tsc = run(`npx tsc --noEmit -p ${tsconfigAppPath}`, '.');
87
+ const tsc = run('npx', ['tsc', '--noEmit', '-p', tsconfigAppPath], '.');
79
88
  if (tsc.ok) {
80
89
  console.log('✓');
81
90
  }
@@ -90,7 +99,7 @@ export async function runCheck() {
90
99
  // `zitejs/*` alias has no generated file behind it; only the bundler knows.
91
100
  if (existsSync(join(appPath, 'src', 'api'))) {
92
101
  process.stdout.write(' bundle endpoints ... ');
93
- const bundle = run(`npx zitejs bundle --app ${app}`, '.');
102
+ const bundle = run('npx', ['zitejs', 'bundle', '--app', app], '.');
94
103
  const failures = bundle.ok ? bundleFailures(bundle.output) : [bundle.output];
95
104
  if (failures.length === 0) {
96
105
  console.log('✓');
@@ -104,7 +113,7 @@ export async function runCheck() {
104
113
  const viteConfig = join(appPath, 'vite.config.ts');
105
114
  if (existsSync(viteConfig)) {
106
115
  process.stdout.write(' vite build ... ');
107
- const vite = run('npx vite build', appPath);
116
+ const vite = run('npx', ['vite', 'build'], appPath);
108
117
  if (vite.ok) {
109
118
  console.log('✓');
110
119
  }
@@ -1,4 +1,3 @@
1
- import type { NotificationsCreateParams, NotificationsCreateResult } from "../notifications/index.js";
2
1
  /**
3
2
  * A comparison against one field. Mirrors base-runner's operator set
4
3
  * (`LegacyWhereConditionOperators`); anything else in the object is ignored.
@@ -121,8 +120,15 @@ export interface TableFindAllOptions<T = Record<string, unknown>> {
121
120
  export interface BulkCreateResult<T> {
122
121
  /** Absent when `records: []` was passed — the dispatch short-circuits before setting it. */
123
122
  success?: boolean;
123
+ /**
124
+ * The nested copy has no `id`: both dispatchers build it with
125
+ * `const { id, ...fields } = record`, so `fields` is the record *minus* the
126
+ * id. Typing it `T` (which requires `id`) made the common
127
+ * `{ id: rec.id, ...rec.fields }` idiom a TS2783 — the type promised the
128
+ * spread always overwrites `id` when at runtime it never does.
129
+ */
124
130
  records: Array<T & {
125
- fields: T;
131
+ fields: Omit<T, "id">;
126
132
  }>;
127
133
  }
128
134
  /** A type alias, not an interface: an interface cannot extend a generic `Partial<T>`. */
@@ -321,9 +327,6 @@ export interface EmailClient {
321
327
  * app's connected email integration key.
322
328
  */
323
329
  export declare function createEmailClient(integrationId: string): EmailClient;
324
- export declare function createNotificationsClient(): {
325
- create(params: NotificationsCreateParams): Promise<NotificationsCreateResult>;
326
- };
327
330
  export type { NotificationLink, NotificationsCreateParams, NotificationsCreateResult, } from "../notifications/index.js";
328
331
  export { createCaller } from "../caller/index.js";
329
332
  export type { EndpointConfig } from "../caller/index.js";
@@ -117,10 +117,4 @@ export function createEmailClient(integrationId) {
117
117
  send: (params) => getSdkCall()(integrationId, "Email", "send", params),
118
118
  };
119
119
  }
120
- const NOTIFICATIONS_SDK_INTEGRATION_ID = "__notifications__";
121
- export function createNotificationsClient() {
122
- return {
123
- create: (params) => getSdkCall()(NOTIFICATIONS_SDK_INTEGRATION_ID, "ZiteNotifications", "create", params),
124
- };
125
- }
126
120
  export { createCaller } from "../caller/index.js";
@@ -11,6 +11,17 @@ describe("BulkCreateResult", () => {
11
11
  expectTypeOf().toEqualTypeOf();
12
12
  expectTypeOf().toEqualTypeOf();
13
13
  });
14
+ // Both dispatchers destructure `id` out before nesting
15
+ // (`const { id, ...fields } = record`), so `fields` must not claim one.
16
+ it("omits id from the nested copy", () => {
17
+ expectTypeOf().toEqualTypeOf();
18
+ });
19
+ // The idiom that regressed under `fields: T` (TS2783, "this spread always
20
+ // overwrites this property"). Compiling at all is the assertion.
21
+ it("allows the id-then-spread idiom", () => {
22
+ const rebuild = (rec) => ({ id: rec.id, ...rec.fields });
23
+ expectTypeOf().toEqualTypeOf();
24
+ });
14
25
  it("keeps success optional", () => {
15
26
  expectTypeOf().toEqualTypeOf();
16
27
  });
@@ -141,6 +141,31 @@ const ATTACHMENT_TYPES = [
141
141
  "",
142
142
  ];
143
143
  const MAX_SELECT_OPTIONS = 100;
144
+ /**
145
+ * A select option label as a TypeScript string-literal type.
146
+ *
147
+ * `JSON.stringify` rather than hand-rolled quote escaping: a label is arbitrary
148
+ * user text, and a newline in one produced an unterminated literal that made the
149
+ * whole of `.zite/db.ts` unparseable — which breaks typecheck for every app in
150
+ * the project, not just the one that owns the table. Backslashes and control
151
+ * characters break it the same way. TS literal syntax is a superset of JSON's
152
+ * for strings, so the output is always valid.
153
+ */
154
+ const tsStringLiteral = (value) => JSON.stringify(value);
155
+ /**
156
+ * The same, single-quoted, for the emitters that write single-quoted source.
157
+ * The escaping still comes from `JSON.stringify` — only the delimiter differs —
158
+ * so newlines, backslashes and control characters stay handled.
159
+ */
160
+ const tsSingleQuoted = (value) => `'${JSON.stringify(value).slice(1, -1).replace(/'/g, "\\'")}'`;
161
+ /**
162
+ * User-controlled text inside a generated comment. Names reach both a `//` line
163
+ * and a one-line JSDoc, so a newline would end the first and a comment-close
164
+ * sequence would end the second — leaving the remainder to parse as code.
165
+ * Applied where the comment is written rather than at each contributor, so
166
+ * anything added to one later is covered by default.
167
+ */
168
+ const commentSafe = (value) => value.replace(/\*\//g, "* /").replace(/\s+/g, " ").trim();
144
169
  const NUMBER_FORMAT_EXAMPLES = {
145
170
  local: "1,000,000.50",
146
171
  comma_period: "1,000,000.50",
@@ -188,7 +213,7 @@ function tsTypeForSchemaField(def, variant = "read") {
188
213
  if (options && options.length > 0) {
189
214
  const literals = options
190
215
  .slice(0, MAX_SELECT_OPTIONS)
191
- .map((o) => `"${o.label.replace(/"/g, '\\"')}"`)
216
+ .map((o) => tsStringLiteral(o.label))
192
217
  .join(" | ");
193
218
  const union = `${literals} | string`;
194
219
  // No `| null` on the read side — see FIELD_TYPE_MAP. The write side keeps
@@ -442,7 +467,7 @@ function buildLinkTableComments(schema) {
442
467
  "// Link tables for zite.sql() JOINs (use these exact names):",
443
468
  ];
444
469
  for (const e of entries) {
445
- lines.push(`// "${e.name}" — columns: "${e.cols[0]}", "${e.cols[1]}"`);
470
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
446
471
  }
447
472
  return lines;
448
473
  }
@@ -523,7 +548,7 @@ export function generateDbTs(inputSchema) {
523
548
  lines.push("// - SELECT only — use .create/.update/.delete for writes");
524
549
  lines.push('// - Load the "zite:sql" skill for full SQL reference (formulas, lookups, etc.)');
525
550
  lines.push(...buildLinkTableComments(schema));
526
- lines.push("import { createTableClient, createSqlClient, createNotificationsClient, createAuthClient } from 'zitejs/runtime';");
551
+ lines.push("import { createTableClient, createSqlClient, createAuthClient } from 'zitejs/runtime';");
527
552
  lines.push("");
528
553
  lines.push(...ATTACHMENT_TYPES);
529
554
  for (const table of tables) {
@@ -538,7 +563,7 @@ export function generateDbTs(inputSchema) {
538
563
  const tsType = tsTypeForSchemaField(field.definition);
539
564
  const jsdoc = fieldJsdoc(field, table, schema);
540
565
  if (jsdoc) {
541
- lines.push(` /** ${jsdoc} */`);
566
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
542
567
  }
543
568
  // Optional, matching the pre-monorepo type (`required: ['id']` — "none of
544
569
  // these are required to be defined aside from 'id'"). A `fields:`
@@ -568,7 +593,10 @@ export function generateDbTs(inputSchema) {
568
593
  lines.push(` ${table.sdkName}: createTableClient<${className}RecordType, ${className}RecordInput>('${className}'),`);
569
594
  }
570
595
  lines.push(` sql: createSqlClient(),`);
571
- lines.push(` notifications: createNotificationsClient(),`);
596
+ // No `notifications` here: it is a platform primitive, not something backed
597
+ // by the project database, so it lives at `zitejs/notifications` alongside
598
+ // `zitejs/pdf` and `zitejs/schedules`. Keeping it on `zite` cost a table
599
+ // named "Notifications" its accessor — see RESERVED_TABLE_ACCESSORS.
572
600
  lines.push(` auth: createAuthClient<ZiteAuthUser>(),`);
573
601
  lines.push("};");
574
602
  lines.push("");
@@ -756,13 +784,13 @@ export function generateApiTs(endpointFiles) {
756
784
  if (skipped.length > 0) {
757
785
  lines.push("// Not endpoints, so no callers were generated for them:");
758
786
  for (const name of skipped)
759
- lines.push(`// ${name}`);
787
+ lines.push(`// ${commentSafe(name)}`);
760
788
  lines.push("");
761
789
  }
762
790
  for (const { pascal, baseName, typed } of endpoints) {
763
791
  if (!typed)
764
792
  continue;
765
- lines.push(`import type { default as _${pascal}Ep } from '../src/api/${baseName}';`);
793
+ lines.push(`import type { default as _${pascal}Ep } from ${tsSingleQuoted(`../src/api/${baseName}`)};`);
766
794
  }
767
795
  lines.push("");
768
796
  for (const { baseName, ident, pascal, stream, typed } of endpoints) {
@@ -770,10 +798,10 @@ export function generateApiTs(endpointFiles) {
770
798
  // Declared with `export const x = createEndpoint(...)` — the 1.0 shape.
771
799
  // The route is real and the bundler deploys it, so the caller has to
772
800
  // exist; there is just no default export to read its types from.
773
- lines.push(`// '${baseName}' has no default export, so its input/output are untyped.`);
801
+ lines.push(`// ${commentSafe(`'${baseName}'`)} has no default export, so its input/output are untyped.`);
774
802
  lines.push(`export type ${pascal}InputType = unknown;`);
775
803
  lines.push(`export type ${pascal}OutputType = unknown;`);
776
- lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
804
+ lines.push(`export const ${ident} = ${stream ? "createStreamingCaller" : "createCaller"}<${pascal}InputType, ${pascal}OutputType>(${tsSingleQuoted(baseName)});`);
777
805
  lines.push("");
778
806
  continue;
779
807
  }
@@ -792,7 +820,7 @@ export function generateApiTs(endpointFiles) {
792
820
  // Calling it `sendEmail` here made every hyphenated or snake_cased endpoint
793
821
  // a 404.
794
822
  const caller = stream ? "createStreamingCaller" : "createCaller";
795
- lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>('${baseName}');`);
823
+ lines.push(`export const ${ident} = ${caller}<${pascal}InputType, ${pascal}OutputType>(${tsSingleQuoted(baseName)});`);
796
824
  lines.push("");
797
825
  }
798
826
  lines.push("");
@@ -862,7 +890,14 @@ const AIRTABLE_FIELD_INPUT_TYPE_MAP = {
862
890
  singleCollaborator: "{ id: string } | { email: string }",
863
891
  multipleCollaborators: "Array<{ id: string } | { email: string }>",
864
892
  };
865
- /** Mirrors `airtable/lib/attachment.d.ts`. Every property but `thumbnails` is present on a read. */
893
+ /**
894
+ * Airtable's attachment object as the REST API really returns it.
895
+ *
896
+ * Not quite `airtable/lib/attachment.d.ts`: that declaration omits `width` and
897
+ * `height`, which the API does return for image attachments. 1.0 exposed them
898
+ * and apps read them, so leaving them out turns a working `photo.width` into a
899
+ * migration type error. Optional, because a non-image attachment has neither.
900
+ */
866
901
  const AIRTABLE_ATTACHMENT_TYPE = [
867
902
  "export type AirtableAttachment = {",
868
903
  " id: string;",
@@ -872,6 +907,10 @@ const AIRTABLE_ATTACHMENT_TYPE = [
872
907
  " size: number;",
873
908
  " /** MIME type. */",
874
909
  " type: string;",
910
+ " /** Pixel width — images only. */",
911
+ " width?: number;",
912
+ " /** Pixel height — images only. */",
913
+ " height?: number;",
875
914
  " thumbnails?: {",
876
915
  " small: { url: string; width: number; height: number };",
877
916
  " large: { url: string; width: number; height: number };",
@@ -900,7 +939,7 @@ function airtableTsType(field, lock, depth, variant = "read") {
900
939
  choices.length > 0) {
901
940
  const literals = choices
902
941
  .slice(0, MAX_SELECT_OPTIONS)
903
- .map((o) => `"${o.replace(/"/g, '\\"')}"`)
942
+ .map((o) => tsStringLiteral(o))
904
943
  .join(" | ");
905
944
  const union = `${literals} | string`;
906
945
  if (field.type === "multipleSelects")
@@ -1046,7 +1085,7 @@ export function generateAirtableTs(lock) {
1046
1085
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1047
1086
  for (const table of lock.tables) {
1048
1087
  const recordType = `${table.sdkName}RecordType`;
1049
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
1088
+ lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1050
1089
  lines.push(`export type ${recordType} = {`);
1051
1090
  lines.push(" id: string;");
1052
1091
  for (const field of table.fields) {
@@ -1054,7 +1093,7 @@ export function generateAirtableTs(lock) {
1054
1093
  continue;
1055
1094
  const jsdoc = airtableFieldJsdoc(field, table, lock);
1056
1095
  if (jsdoc) {
1057
- lines.push(` /** ${jsdoc} */`);
1096
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
1058
1097
  }
1059
1098
  const tsType = airtableTsType(field, lock);
1060
1099
  // Optional, because Airtable omits a field from the response entirely
@@ -1068,7 +1107,7 @@ export function generateAirtableTs(lock) {
1068
1107
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1069
1108
  // to a formula, rollup, lookup or autonumber with a 422.
1070
1109
  const writableFields = table.fields.filter((f) => f.sdkName !== "id" && !READ_ONLY_AIRTABLE_FIELDS.has(f.type));
1071
- lines.push(`/** What you may write when creating or updating a ${table.sdkName}. */`);
1110
+ lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1072
1111
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1073
1112
  for (const field of writableFields) {
1074
1113
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1234,7 +1273,7 @@ export function generateEmailSdk(integrationId) {
1234
1273
  " SendEmailResult,",
1235
1274
  "} from 'zitejs/runtime';",
1236
1275
  "",
1237
- `export const Email = createEmailClient('${integrationId}');`,
1276
+ `export const Email = createEmailClient(${tsSingleQuoted(integrationId)});`,
1238
1277
  "",
1239
1278
  ].join("\n");
1240
1279
  }
@@ -1,5 +1,156 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateBackendWrapperTs } from './lib.js';
2
+ import ts from 'typescript';
3
+ import { generateAirtableTs, generateApiTs, generateBackendWrapperTs, generateDbTs, generateEmailSdk, } from './lib.js';
4
+ /**
5
+ * Syntax errors in the emitted source. `.zite/db.ts` sits at the repo root and
6
+ * every app imports it, so anything unparseable here fails typecheck for the
7
+ * whole project — and the file is generated, so no one can edit their way out.
8
+ */
9
+ const syntaxErrorsIn = (source) => (ts.transpileModule(source, {
10
+ reportDiagnostics: true,
11
+ compilerOptions: { target: ts.ScriptTarget.Latest },
12
+ }).diagnostics ?? []).map(d => ts.flattenDiagnosticMessageText(d.messageText, ' '));
13
+ const schemaWithSelectOption = (label) => ({
14
+ tables: [
15
+ {
16
+ id: 'tbl1',
17
+ sdkName: 'registrations',
18
+ fields: [
19
+ {
20
+ id: 'fld1',
21
+ sdkName: 'ticketType',
22
+ definition: {
23
+ type: 'single_select',
24
+ template: { options: [{ label }] },
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ ],
30
+ });
31
+ describe('generateDbTs select option literals', () => {
32
+ // Reported from a migrated app: a consent paragraph pasted in as an option
33
+ // label carried a newline, so the emitted literal never closed and the whole
34
+ // generated file failed with TS1002 — taking every sibling app with it.
35
+ it.each([
36
+ ['a newline', 'I am 18 or above.\nI agree to the rules.'],
37
+ ['a carriage return', 'Lite Pass\r\nRegular Pass'],
38
+ ['a double quote', 'The "Premium" tier'],
39
+ ['a trailing backslash', 'Group / Crew Purchase\\'],
40
+ ['a tab', 'Lite\tPass'],
41
+ ])('emits parseable TypeScript for a label containing %s', (_what, label) => {
42
+ expect(syntaxErrorsIn(generateDbTs(schemaWithSelectOption(label)))).toEqual([]);
43
+ });
44
+ it('keeps the option readable in the union', () => {
45
+ const out = generateDbTs(schemaWithSelectOption('The "Premium" tier'));
46
+ expect(out).toContain('"The \\"Premium\\" tier"');
47
+ });
48
+ });
49
+ describe('generateAirtableTs attachment type', () => {
50
+ const lockWithAttachment = {
51
+ integrationId: 'airtable',
52
+ tables: [
53
+ {
54
+ id: 'tbl1',
55
+ sdkName: 'Speakers',
56
+ primaryFieldId: 'fld1',
57
+ fields: [
58
+ { id: 'fld1', sdkName: 'name', type: 'singleLineText', name: 'Name' },
59
+ {
60
+ id: 'fld2',
61
+ sdkName: 'headshot',
62
+ type: 'multipleAttachments',
63
+ name: 'Headshot',
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+ // Reported from a migrated app reading `photo.width` to lay out an image
70
+ // grid. Airtable returns width/height on image attachments and 1.0 exposed
71
+ // them; `airtable/lib/attachment.d.ts`, which this type was copied from,
72
+ // omits both — so the read became a TS2339 the app could not edit away.
73
+ it.each(['width', 'height'])('exposes %s, which Airtable returns for images', prop => {
74
+ const out = generateAirtableTs(lockWithAttachment);
75
+ expect(out).not.toBeNull();
76
+ expect(out).toContain(` ${prop}?: number;`);
77
+ });
78
+ it('emits parseable TypeScript', () => {
79
+ expect(syntaxErrorsIn(generateAirtableTs(lockWithAttachment) ?? '')).toEqual([]);
80
+ });
81
+ });
82
+ describe('generateDbTs field names in comments', () => {
83
+ // A field's display name is user text and reaches a one-line JSDoc. A
84
+ // comment-close sequence in it would end the comment early and leave the
85
+ // remainder to parse as code.
86
+ const schemaWithFieldNamed = (name) => ({
87
+ tables: [
88
+ {
89
+ id: 'tbl1',
90
+ sdkName: 'orders',
91
+ primaryFieldId: 'fld1',
92
+ fields: [
93
+ {
94
+ id: 'fld1',
95
+ sdkName: 'amount',
96
+ definition: {
97
+ type: 'currency',
98
+ name,
99
+ template: { currencySymbol: '$' },
100
+ },
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ });
106
+ it.each([
107
+ ['a comment-close sequence', 'Total */ console.log(1); /*'],
108
+ ['a newline', 'Total\namount'],
109
+ ])('emits parseable TypeScript for a field named with %s', (_what, name) => {
110
+ expect(syntaxErrorsIn(generateDbTs(schemaWithFieldNamed(name)))).toEqual([]);
111
+ });
112
+ });
113
+ describe('generateApiTs endpoint file names', () => {
114
+ // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
115
+ // validates its characters — and it lands in an import specifier, a comment
116
+ // and two string literals. `.zite/api.ts` is imported by every component that
117
+ // calls `api.*`, so one bad name takes the whole app down.
118
+ it.each([
119
+ ['an apostrophe', "it's.ts"],
120
+ ['a newline', 'ok\nreport.ts'],
121
+ ['a trailing backslash', 'back\\.ts'],
122
+ ['a double quote', 'say"hi.ts'],
123
+ ])('emits parseable TypeScript for a file named with %s', (_what, name) => {
124
+ const out = generateApiTs([
125
+ { fileName: name, content: 'export default createEndpoint({});' },
126
+ ]);
127
+ expect(out).not.toBeNull();
128
+ expect(syntaxErrorsIn(out)).toEqual([]);
129
+ });
130
+ // The no-default-export branch puts the name in a `//` comment instead, so it
131
+ // needs its own case: there a line terminator ends the comment, not a string.
132
+ it('emits parseable TypeScript for an untyped endpoint with a newline', () => {
133
+ const out = generateApiTs([
134
+ {
135
+ fileName: 'ok\nreport.ts',
136
+ content: 'export const report = createEndpoint({});',
137
+ },
138
+ ]);
139
+ expect(out).not.toBeNull();
140
+ expect(syntaxErrorsIn(out)).toEqual([]);
141
+ });
142
+ });
143
+ describe('generateEmailSdk integration id', () => {
144
+ // An LLM-authored `zite.config.json` key, validated only as
145
+ // `z.record(z.string(), …)`.
146
+ it.each([
147
+ ['an apostrophe', "resend'prod"],
148
+ ['a newline', 'resend\nprod'],
149
+ ['a trailing backslash', 'resend\\'],
150
+ ])('emits parseable TypeScript for an id with %s', (_what, id) => {
151
+ expect(syntaxErrorsIn(generateEmailSdk(id))).toEqual([]);
152
+ });
153
+ });
3
154
  describe('generateBackendWrapperTs', () => {
4
155
  const output = generateBackendWrapperTs();
5
156
  it('imports User from zitejs/auth', () => {
@@ -36,8 +36,15 @@ export declare function toSdkName(name: string): string;
36
36
  /**
37
37
  * Accessors `generateDbTs` puts on the `zite` object itself, after the tables.
38
38
  * A table that allocates one of these produces a duplicate key.
39
+ *
40
+ * `notifications` used to be here and no longer is: it was a platform
41
+ * primitive hanging off `zite`, so a table named "Notifications" collided with
42
+ * it and — because the sentinel was written last — lost its client entirely.
43
+ * Moving it to `zitejs/notifications` removes the collision rather than
44
+ * managing it, and frees the name for the table that wants it. Everything left
45
+ * is genuinely backed by the project database.
39
46
  */
40
- export declare const RESERVED_TABLE_ACCESSORS: readonly ["sql", "notifications", "auth"];
47
+ export declare const RESERVED_TABLE_ACCESSORS: readonly ["sql", "auth"];
41
48
  /**
42
49
  * Never safe as a generated key, in any namespace. Assigning `__proto__` in an
43
50
  * object literal sets the prototype rather than defining a property, and
@@ -56,12 +56,15 @@ export function toSdkName(name) {
56
56
  /**
57
57
  * Accessors `generateDbTs` puts on the `zite` object itself, after the tables.
58
58
  * A table that allocates one of these produces a duplicate key.
59
+ *
60
+ * `notifications` used to be here and no longer is: it was a platform
61
+ * primitive hanging off `zite`, so a table named "Notifications" collided with
62
+ * it and — because the sentinel was written last — lost its client entirely.
63
+ * Moving it to `zitejs/notifications` removes the collision rather than
64
+ * managing it, and frees the name for the table that wants it. Everything left
65
+ * is genuinely backed by the project database.
59
66
  */
60
- export const RESERVED_TABLE_ACCESSORS = [
61
- "sql",
62
- "notifications",
63
- "auth",
64
- ];
67
+ export const RESERVED_TABLE_ACCESSORS = ["sql", "auth"];
65
68
  /**
66
69
  * Never safe as a generated key, in any namespace. Assigning `__proto__` in an
67
70
  * object literal sets the prototype rather than defining a property, and
@@ -31,17 +31,18 @@ const recordTypeKeys = (source, typeName) => {
31
31
  };
32
32
  describe("reserved platform accessors", () => {
33
33
  // Sudzy Dashboard: a table named "Notifications" emitted a second
34
- // `notifications:` key, and the platform client — written last — won. The
34
+ // `notifications:` key and the platform client — written last — won, so the
35
35
  // table lost findAll and typed as `{ create(NotificationsCreateParams) }`.
36
- it("moves a table off the notifications accessor", () => {
36
+ // The platform client has since moved to `zitejs/notifications`, so the name
37
+ // belongs to the table again and there is nothing left to collide with.
38
+ it("lets a table keep the notifications accessor", () => {
37
39
  const schema = generateSchema(database([{ id: "tbl_1", name: "Notifications", fields: [] }]));
38
- expect(schema.tables[0].sdkName).toBe("notifications2");
40
+ expect(schema.tables[0].sdkName).toBe("notifications");
39
41
  const keys = ziteKeys(generateDbTs(schema));
40
- expect(keys).toContain("notifications2");
41
42
  expect(keys.filter((k) => k === "notifications")).toHaveLength(1);
42
43
  expect(new Set(keys).size).toBe(keys.length);
43
44
  });
44
- it.each(["sql", "auth", "notifications"])("keeps the platform %s accessor for the platform", (reserved) => {
45
+ it.each(["sql", "auth"])("keeps the platform %s accessor for the platform", (reserved) => {
45
46
  const schema = generateSchema(database([{ id: "tbl_1", name: reserved, fields: [] }]));
46
47
  expect(schema.tables[0].sdkName).toBe(`${reserved}2`);
47
48
  const keys = ziteKeys(generateDbTs(schema));
@@ -190,14 +191,17 @@ describe("normalizeSchemaNames", () => {
190
191
  // `zitejs generate` reads it with no generateSchema pass in front, so this is
191
192
  // the only thing standing between it and a TS1117 it cannot build past.
192
193
  it("repairs a committed schema that already holds the collision", () => {
194
+ // `sql`, not `notifications` — the latter stopped being a platform
195
+ // accessor when the client moved to `zitejs/notifications`, so a schema
196
+ // holding it is no longer a collision to repair.
193
197
  const source = generateDbTs({
194
198
  tables: [
195
- { id: "tbl_1", sdkName: "notifications", fields: [] },
199
+ { id: "tbl_1", sdkName: "sql", fields: [] },
196
200
  { id: "tbl_2", sdkName: "orders", fields: [] },
197
201
  ],
198
202
  });
199
203
  const keys = ziteKeys(source);
200
204
  expect(new Set(keys).size).toBe(keys.length);
201
- expect(keys).toContain("notifications2");
205
+ expect(keys).toContain("sql2");
202
206
  });
203
207
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.107",
3
+ "version": "0.9.109",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",