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.
- package/dist/cjs/check/index.js +14 -5
- package/dist/cjs/runtime/index.d.ts +8 -1
- package/dist/cjs/runtime/writeResultTypes.test-d.js +11 -0
- package/dist/cjs/sync/lib.js +41 -13
- package/dist/cjs/sync/lib.test.js +121 -0
- package/dist/esm/check/index.js +15 -6
- package/dist/esm/runtime/index.d.ts +8 -1
- package/dist/esm/runtime/writeResultTypes.test-d.js +11 -0
- package/dist/esm/sync/lib.js +41 -13
- package/dist/esm/sync/lib.test.js +119 -1
- package/package.json +1 -1
package/dist/cjs/check/index.js
CHANGED
|
@@ -12,9 +12,18 @@ function findAppDirs() {
|
|
|
12
12
|
.filter(d => d.isDirectory())
|
|
13
13
|
.map(d => d.name);
|
|
14
14
|
}
|
|
15
|
-
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
});
|
package/dist/cjs/sync/lib.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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>(
|
|
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>(
|
|
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) =>
|
|
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(
|
|
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', () => {
|
package/dist/esm/check/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
});
|
package/dist/esm/sync/lib.js
CHANGED
|
@@ -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) =>
|
|
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
|
|
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>(
|
|
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>(
|
|
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) =>
|
|
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(
|
|
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
|
|
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', () => {
|