zitejs 0.9.107 → 0.9.108

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
  }
@@ -121,8 +121,15 @@ export interface TableFindAllOptions<T = Record<string, unknown>> {
121
121
  export interface BulkCreateResult<T> {
122
122
  /** Absent when `records: []` was passed — the dispatch short-circuits before setting it. */
123
123
  success?: boolean;
124
+ /**
125
+ * The nested copy has no `id`: both dispatchers build it with
126
+ * `const { id, ...fields } = record`, so `fields` is the record *minus* the
127
+ * id. Typing it `T` (which requires `id`) made the common
128
+ * `{ id: rec.id, ...rec.fields }` idiom a TS2783 — the type promised the
129
+ * spread always overwrites `id` when at runtime it never does.
130
+ */
124
131
  records: Array<T & {
125
- fields: T;
132
+ fields: Omit<T, "id">;
126
133
  }>;
127
134
  }
128
135
  /** A type alias, not an interface: an interface cannot extend a generic `Partial<T>`. */
@@ -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,34 @@ 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
178
+ .replace(/\*\//g, "* /")
179
+ .replace(/\s+/g, " ")
180
+ .trim();
153
181
  const NUMBER_FORMAT_EXAMPLES = {
154
182
  local: "1,000,000.50",
155
183
  comma_period: "1,000,000.50",
@@ -200,7 +228,7 @@ function tsTypeForSchemaField(def, variant = "read") {
200
228
  if (options && options.length > 0) {
201
229
  const literals = options
202
230
  .slice(0, MAX_SELECT_OPTIONS)
203
- .map((o) => `"${o.label.replace(/"/g, '\\"')}"`)
231
+ .map((o) => tsStringLiteral(o.label))
204
232
  .join(" | ");
205
233
  const union = `${literals} | string`;
206
234
  // No `| null` on the read side — see FIELD_TYPE_MAP. The write side keeps
@@ -454,7 +482,7 @@ function buildLinkTableComments(schema) {
454
482
  "// Link tables for zite.sql() JOINs (use these exact names):",
455
483
  ];
456
484
  for (const e of entries) {
457
- lines.push(`// "${e.name}" — columns: "${e.cols[0]}", "${e.cols[1]}"`);
485
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
458
486
  }
459
487
  return lines;
460
488
  }
@@ -550,7 +578,7 @@ function generateDbTs(inputSchema) {
550
578
  const tsType = tsTypeForSchemaField(field.definition);
551
579
  const jsdoc = fieldJsdoc(field, table, schema);
552
580
  if (jsdoc) {
553
- lines.push(` /** ${jsdoc} */`);
581
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
554
582
  }
555
583
  // Optional, matching the pre-monorepo type (`required: ['id']` — "none of
556
584
  // these are required to be defined aside from 'id'"). A `fields:`
@@ -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("");
@@ -912,7 +940,7 @@ function airtableTsType(field, lock, depth, variant = "read") {
912
940
  choices.length > 0) {
913
941
  const literals = choices
914
942
  .slice(0, MAX_SELECT_OPTIONS)
915
- .map((o) => `"${o.replace(/"/g, '\\"')}"`)
943
+ .map((o) => tsStringLiteral(o))
916
944
  .join(" | ");
917
945
  const union = `${literals} | string`;
918
946
  if (field.type === "multipleSelects")
@@ -1058,7 +1086,7 @@ function generateAirtableTs(lock) {
1058
1086
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1059
1087
  for (const table of lock.tables) {
1060
1088
  const recordType = `${table.sdkName}RecordType`;
1061
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
1089
+ lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1062
1090
  lines.push(`export type ${recordType} = {`);
1063
1091
  lines.push(" id: string;");
1064
1092
  for (const field of table.fields) {
@@ -1066,7 +1094,7 @@ function generateAirtableTs(lock) {
1066
1094
  continue;
1067
1095
  const jsdoc = airtableFieldJsdoc(field, table, lock);
1068
1096
  if (jsdoc) {
1069
- lines.push(` /** ${jsdoc} */`);
1097
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
1070
1098
  }
1071
1099
  const tsType = airtableTsType(field, lock);
1072
1100
  // Optional, because Airtable omits a field from the response entirely
@@ -1080,7 +1108,7 @@ function generateAirtableTs(lock) {
1080
1108
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1081
1109
  // to a formula, rollup, lookup or autonumber with a 422.
1082
1110
  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}. */`);
1111
+ lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1084
1112
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1085
1113
  for (const field of writableFields) {
1086
1114
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1246,7 +1274,7 @@ function generateEmailSdk(integrationId) {
1246
1274
  " SendEmailResult,",
1247
1275
  "} from 'zitejs/runtime';",
1248
1276
  "",
1249
- `export const Email = createEmailClient('${integrationId}');`,
1277
+ `export const Email = createEmailClient(${tsSingleQuoted(integrationId)});`,
1250
1278
  "",
1251
1279
  ].join("\n");
1252
1280
  }
@@ -1,7 +1,128 @@
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)('generateDbTs field names in comments', () => {
55
+ // A field's display name is user text and reaches a one-line JSDoc. A
56
+ // comment-close sequence in it would end the comment early and leave the
57
+ // remainder to parse as code.
58
+ const schemaWithFieldNamed = (name) => ({
59
+ tables: [
60
+ {
61
+ id: 'tbl1',
62
+ sdkName: 'orders',
63
+ primaryFieldId: 'fld1',
64
+ fields: [
65
+ {
66
+ id: 'fld1',
67
+ sdkName: 'amount',
68
+ definition: {
69
+ type: 'currency',
70
+ name,
71
+ template: { currencySymbol: '$' },
72
+ },
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ });
78
+ vitest_1.it.each([
79
+ ['a comment-close sequence', 'Total */ console.log(1); /*'],
80
+ ['a newline', 'Total\namount'],
81
+ ])('emits parseable TypeScript for a field named with %s', (_what, name) => {
82
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateDbTs)(schemaWithFieldNamed(name)))).toEqual([]);
83
+ });
84
+ });
85
+ (0, vitest_1.describe)('generateApiTs endpoint file names', () => {
86
+ // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
87
+ // validates its characters — and it lands in an import specifier, a comment
88
+ // and two string literals. `.zite/api.ts` is imported by every component that
89
+ // calls `api.*`, so one bad name takes the whole app down.
90
+ vitest_1.it.each([
91
+ ['an apostrophe', "it's.ts"],
92
+ ['a newline', 'ok\nreport.ts'],
93
+ ['a trailing backslash', 'back\\.ts'],
94
+ ['a double quote', 'say"hi.ts'],
95
+ ])('emits parseable TypeScript for a file named with %s', (_what, name) => {
96
+ const out = (0, lib_js_1.generateApiTs)([
97
+ { fileName: name, content: 'export default createEndpoint({});' },
98
+ ]);
99
+ (0, vitest_1.expect)(out).not.toBeNull();
100
+ (0, vitest_1.expect)(syntaxErrorsIn(out)).toEqual([]);
101
+ });
102
+ // The no-default-export branch puts the name in a `//` comment instead, so it
103
+ // needs its own case: there a line terminator ends the comment, not a string.
104
+ (0, vitest_1.it)('emits parseable TypeScript for an untyped endpoint with a newline', () => {
105
+ const out = (0, lib_js_1.generateApiTs)([
106
+ {
107
+ fileName: 'ok\nreport.ts',
108
+ content: 'export const report = createEndpoint({});',
109
+ },
110
+ ]);
111
+ (0, vitest_1.expect)(out).not.toBeNull();
112
+ (0, vitest_1.expect)(syntaxErrorsIn(out)).toEqual([]);
113
+ });
114
+ });
115
+ (0, vitest_1.describe)('generateEmailSdk integration id', () => {
116
+ // An LLM-authored `zite.config.json` key, validated only as
117
+ // `z.record(z.string(), …)`.
118
+ vitest_1.it.each([
119
+ ['an apostrophe', "resend'prod"],
120
+ ['a newline', 'resend\nprod'],
121
+ ['a trailing backslash', 'resend\\'],
122
+ ])('emits parseable TypeScript for an id with %s', (_what, id) => {
123
+ (0, vitest_1.expect)(syntaxErrorsIn((0, lib_js_1.generateEmailSdk)(id))).toEqual([]);
124
+ });
125
+ });
5
126
  (0, vitest_1.describe)('generateBackendWrapperTs', () => {
6
127
  const output = (0, lib_js_1.generateBackendWrapperTs)();
7
128
  (0, vitest_1.it)('imports User from zitejs/auth', () => {
@@ -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
  }
@@ -121,8 +121,15 @@ export interface TableFindAllOptions<T = Record<string, unknown>> {
121
121
  export interface BulkCreateResult<T> {
122
122
  /** Absent when `records: []` was passed — the dispatch short-circuits before setting it. */
123
123
  success?: boolean;
124
+ /**
125
+ * The nested copy has no `id`: both dispatchers build it with
126
+ * `const { id, ...fields } = record`, so `fields` is the record *minus* the
127
+ * id. Typing it `T` (which requires `id`) made the common
128
+ * `{ id: rec.id, ...rec.fields }` idiom a TS2783 — the type promised the
129
+ * spread always overwrites `id` when at runtime it never does.
130
+ */
124
131
  records: Array<T & {
125
- fields: T;
132
+ fields: Omit<T, "id">;
126
133
  }>;
127
134
  }
128
135
  /** A type alias, not an interface: an interface cannot extend a generic `Partial<T>`. */
@@ -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,34 @@ 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
169
+ .replace(/\*\//g, "* /")
170
+ .replace(/\s+/g, " ")
171
+ .trim();
144
172
  const NUMBER_FORMAT_EXAMPLES = {
145
173
  local: "1,000,000.50",
146
174
  comma_period: "1,000,000.50",
@@ -188,7 +216,7 @@ function tsTypeForSchemaField(def, variant = "read") {
188
216
  if (options && options.length > 0) {
189
217
  const literals = options
190
218
  .slice(0, MAX_SELECT_OPTIONS)
191
- .map((o) => `"${o.label.replace(/"/g, '\\"')}"`)
219
+ .map((o) => tsStringLiteral(o.label))
192
220
  .join(" | ");
193
221
  const union = `${literals} | string`;
194
222
  // No `| null` on the read side — see FIELD_TYPE_MAP. The write side keeps
@@ -442,7 +470,7 @@ function buildLinkTableComments(schema) {
442
470
  "// Link tables for zite.sql() JOINs (use these exact names):",
443
471
  ];
444
472
  for (const e of entries) {
445
- lines.push(`// "${e.name}" — columns: "${e.cols[0]}", "${e.cols[1]}"`);
473
+ lines.push(`// "${commentSafe(e.name)}" — columns: "${commentSafe(e.cols[0])}", "${commentSafe(e.cols[1])}"`);
446
474
  }
447
475
  return lines;
448
476
  }
@@ -538,7 +566,7 @@ export function generateDbTs(inputSchema) {
538
566
  const tsType = tsTypeForSchemaField(field.definition);
539
567
  const jsdoc = fieldJsdoc(field, table, schema);
540
568
  if (jsdoc) {
541
- lines.push(` /** ${jsdoc} */`);
569
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
542
570
  }
543
571
  // Optional, matching the pre-monorepo type (`required: ['id']` — "none of
544
572
  // these are required to be defined aside from 'id'"). A `fields:`
@@ -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("");
@@ -900,7 +928,7 @@ function airtableTsType(field, lock, depth, variant = "read") {
900
928
  choices.length > 0) {
901
929
  const literals = choices
902
930
  .slice(0, MAX_SELECT_OPTIONS)
903
- .map((o) => `"${o.replace(/"/g, '\\"')}"`)
931
+ .map((o) => tsStringLiteral(o))
904
932
  .join(" | ");
905
933
  const union = `${literals} | string`;
906
934
  if (field.type === "multipleSelects")
@@ -1046,7 +1074,7 @@ export function generateAirtableTs(lock) {
1046
1074
  lines.push(...AIRTABLE_ATTACHMENT_TYPE);
1047
1075
  for (const table of lock.tables) {
1048
1076
  const recordType = `${table.sdkName}RecordType`;
1049
- lines.push(`/** A ${table.sdkName} record as it is read back. */`);
1077
+ lines.push(`/** A ${commentSafe(table.sdkName)} record as it is read back. */`);
1050
1078
  lines.push(`export type ${recordType} = {`);
1051
1079
  lines.push(" id: string;");
1052
1080
  for (const field of table.fields) {
@@ -1054,7 +1082,7 @@ export function generateAirtableTs(lock) {
1054
1082
  continue;
1055
1083
  const jsdoc = airtableFieldJsdoc(field, table, lock);
1056
1084
  if (jsdoc) {
1057
- lines.push(` /** ${jsdoc} */`);
1085
+ lines.push(` /** ${commentSafe(jsdoc)} */`);
1058
1086
  }
1059
1087
  const tsType = airtableTsType(field, lock);
1060
1088
  // Optional, because Airtable omits a field from the response entirely
@@ -1068,7 +1096,7 @@ export function generateAirtableTs(lock) {
1068
1096
  // Read-only fields are omitted rather than typed: Airtable rejects a write
1069
1097
  // to a formula, rollup, lookup or autonumber with a 422.
1070
1098
  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}. */`);
1099
+ lines.push(`/** What you may write when creating or updating a ${commentSafe(table.sdkName)}. */`);
1072
1100
  lines.push(`export type ${table.sdkName}RecordInput = {`);
1073
1101
  for (const field of writableFields) {
1074
1102
  lines.push(` ${field.sdkName}: ${airtableTsType(field, lock, 0, "write")};`);
@@ -1234,7 +1262,7 @@ export function generateEmailSdk(integrationId) {
1234
1262
  " SendEmailResult,",
1235
1263
  "} from 'zitejs/runtime';",
1236
1264
  "",
1237
- `export const Email = createEmailClient('${integrationId}');`,
1265
+ `export const Email = createEmailClient(${tsSingleQuoted(integrationId)});`,
1238
1266
  "",
1239
1267
  ].join("\n");
1240
1268
  }
@@ -1,5 +1,123 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { generateBackendWrapperTs } from './lib.js';
2
+ import ts from 'typescript';
3
+ import { 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('generateDbTs field names in comments', () => {
50
+ // A field's display name is user text and reaches a one-line JSDoc. A
51
+ // comment-close sequence in it would end the comment early and leave the
52
+ // remainder to parse as code.
53
+ const schemaWithFieldNamed = (name) => ({
54
+ tables: [
55
+ {
56
+ id: 'tbl1',
57
+ sdkName: 'orders',
58
+ primaryFieldId: 'fld1',
59
+ fields: [
60
+ {
61
+ id: 'fld1',
62
+ sdkName: 'amount',
63
+ definition: {
64
+ type: 'currency',
65
+ name,
66
+ template: { currencySymbol: '$' },
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ });
73
+ it.each([
74
+ ['a comment-close sequence', 'Total */ console.log(1); /*'],
75
+ ['a newline', 'Total\namount'],
76
+ ])('emits parseable TypeScript for a field named with %s', (_what, name) => {
77
+ expect(syntaxErrorsIn(generateDbTs(schemaWithFieldNamed(name)))).toEqual([]);
78
+ });
79
+ });
80
+ describe('generateApiTs endpoint file names', () => {
81
+ // An endpoint filename is the LLM's raw `writeFile` path argument — nothing
82
+ // validates its characters — and it lands in an import specifier, a comment
83
+ // and two string literals. `.zite/api.ts` is imported by every component that
84
+ // calls `api.*`, so one bad name takes the whole app down.
85
+ it.each([
86
+ ['an apostrophe', "it's.ts"],
87
+ ['a newline', 'ok\nreport.ts'],
88
+ ['a trailing backslash', 'back\\.ts'],
89
+ ['a double quote', 'say"hi.ts'],
90
+ ])('emits parseable TypeScript for a file named with %s', (_what, name) => {
91
+ const out = generateApiTs([
92
+ { fileName: name, content: 'export default createEndpoint({});' },
93
+ ]);
94
+ expect(out).not.toBeNull();
95
+ expect(syntaxErrorsIn(out)).toEqual([]);
96
+ });
97
+ // The no-default-export branch puts the name in a `//` comment instead, so it
98
+ // needs its own case: there a line terminator ends the comment, not a string.
99
+ it('emits parseable TypeScript for an untyped endpoint with a newline', () => {
100
+ const out = generateApiTs([
101
+ {
102
+ fileName: 'ok\nreport.ts',
103
+ content: 'export const report = createEndpoint({});',
104
+ },
105
+ ]);
106
+ expect(out).not.toBeNull();
107
+ expect(syntaxErrorsIn(out)).toEqual([]);
108
+ });
109
+ });
110
+ describe('generateEmailSdk integration id', () => {
111
+ // An LLM-authored `zite.config.json` key, validated only as
112
+ // `z.record(z.string(), …)`.
113
+ it.each([
114
+ ['an apostrophe', "resend'prod"],
115
+ ['a newline', 'resend\nprod'],
116
+ ['a trailing backslash', 'resend\\'],
117
+ ])('emits parseable TypeScript for an id with %s', (_what, id) => {
118
+ expect(syntaxErrorsIn(generateEmailSdk(id))).toEqual([]);
119
+ });
120
+ });
3
121
  describe('generateBackendWrapperTs', () => {
4
122
  const output = generateBackendWrapperTs();
5
123
  it('imports User from zitejs/auth', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.107",
3
+ "version": "0.9.108",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",