zitejs 0.9.113 → 0.9.115
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/api/index.js +5 -0
- package/dist/cjs/backend/index.d.ts +13 -0
- package/dist/cjs/backend/index.js +63 -1
- package/dist/cjs/backend/index.test.d.ts +1 -0
- package/dist/cjs/backend/index.test.js +203 -0
- package/dist/cjs/bundle/index.d.ts +24 -0
- package/dist/cjs/bundle/index.js +20 -12
- package/dist/cjs/db/index.js +5 -0
- package/dist/cjs/runtime/index.js +108 -22
- package/dist/cjs/runtime/index.test.d.ts +1 -0
- package/dist/cjs/runtime/index.test.js +133 -0
- package/dist/cjs/sync/lib.js +14 -0
- package/dist/cjs/sync/sdkNames.d.ts +2 -1
- package/dist/cjs/sync/sdkNames.js +17 -1
- package/dist/cjs/sync/sdkNames.test.js +48 -0
- package/dist/esm/api/index.d.ts +2 -0
- package/dist/esm/api/index.js +1 -0
- package/dist/esm/backend/index.d.ts +13 -0
- package/dist/esm/backend/index.js +63 -1
- package/dist/esm/backend/index.test.d.ts +1 -0
- package/dist/esm/backend/index.test.js +201 -0
- package/dist/esm/bundle/index.d.ts +24 -0
- package/dist/esm/bundle/index.js +16 -9
- package/dist/esm/cli.js +0 -0
- package/dist/esm/db/index.d.ts +2 -0
- package/dist/esm/db/index.js +1 -0
- package/dist/esm/runtime/index.js +108 -22
- package/dist/esm/runtime/index.test.d.ts +1 -0
- package/dist/esm/runtime/index.test.js +131 -0
- package/dist/esm/sync/lib.js +14 -0
- package/dist/esm/sync/sdkNames.d.ts +2 -1
- package/dist/esm/sync/sdkNames.js +17 -1
- package/dist/esm/sync/sdkNames.test.js +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const index_js_1 = require("./index.js");
|
|
5
|
+
/** Captures what the runtime would have dispatched to the workflow-runner. */
|
|
6
|
+
function stubSdkCall(result = undefined) {
|
|
7
|
+
const calls = [];
|
|
8
|
+
vitest_1.vi.stubGlobal("__wrapSdkCall", async (_integrationId, _className, method, params) => {
|
|
9
|
+
calls.push({ method, params });
|
|
10
|
+
return result;
|
|
11
|
+
});
|
|
12
|
+
return calls;
|
|
13
|
+
}
|
|
14
|
+
(0, vitest_1.afterEach)(() => {
|
|
15
|
+
vitest_1.vi.unstubAllGlobals();
|
|
16
|
+
});
|
|
17
|
+
(0, vitest_1.describe)("the mechanism", () => {
|
|
18
|
+
(0, vitest_1.it)("drops an undefined filter value on serialization, leaving an unfiltered read", () => {
|
|
19
|
+
const token = undefined;
|
|
20
|
+
const wire = JSON.stringify({ filters: { tokenFraisAdmin: token } });
|
|
21
|
+
(0, vitest_1.expect)(wire).toBe('{"filters":{}}');
|
|
22
|
+
(0, vitest_1.expect)(JSON.parse(wire).filters).toEqual({});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
(0, vitest_1.describe)("findOne", () => {
|
|
26
|
+
(0, vitest_1.it)("matches nothing when a filter value is undefined, without dispatching", async () => {
|
|
27
|
+
// The incident shape. `if (!row) return { status: 'invalid_token' }` is
|
|
28
|
+
// what callers already write, so answering "no match" makes that code
|
|
29
|
+
// correct on its own — where returning the first row made it lie.
|
|
30
|
+
const calls = stubSdkCall();
|
|
31
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
32
|
+
const token = undefined;
|
|
33
|
+
const row = await clients.findOne({
|
|
34
|
+
filters: { tokenFraisAdmin: token },
|
|
35
|
+
});
|
|
36
|
+
(0, vitest_1.expect)(row).toBeUndefined();
|
|
37
|
+
(0, vitest_1.expect)(calls).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
(0, vitest_1.it)("warns so a missing value is still discoverable in the run log", async () => {
|
|
40
|
+
// A silent miss looks exactly like a legitimate no-match, which would hide
|
|
41
|
+
// the bug that produced it.
|
|
42
|
+
const warn = vitest_1.vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
43
|
+
stubSdkCall();
|
|
44
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
45
|
+
await clients.findOne({ filters: { tokenFraisAdmin: undefined } });
|
|
46
|
+
(0, vitest_1.expect)(warn).toHaveBeenCalledWith(vitest_1.expect.stringContaining('filter "tokenFraisAdmin" is undefined'));
|
|
47
|
+
warn.mockRestore();
|
|
48
|
+
});
|
|
49
|
+
(0, vitest_1.it)("matches nothing on an undefined operand inside a filter condition", async () => {
|
|
50
|
+
const calls = stubSdkCall();
|
|
51
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
52
|
+
const row = await clients.findOne({
|
|
53
|
+
filters: { nom: { contains: undefined } },
|
|
54
|
+
});
|
|
55
|
+
(0, vitest_1.expect)(row).toBeUndefined();
|
|
56
|
+
(0, vitest_1.expect)(calls).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
(0, vitest_1.it)("matches nothing on an undefined id when nothing else narrows the query", async () => {
|
|
59
|
+
const calls = stubSdkCall();
|
|
60
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
61
|
+
const id = undefined;
|
|
62
|
+
const row = await clients.findOne({ id });
|
|
63
|
+
(0, vitest_1.expect)(row).toBeUndefined();
|
|
64
|
+
(0, vitest_1.expect)(calls).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
(0, vitest_1.it)("still allows an undefined id when filters narrow the query", async () => {
|
|
67
|
+
const calls = stubSdkCall();
|
|
68
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
69
|
+
await clients.findOne({ id: undefined, filters: { nom: "Henry" } });
|
|
70
|
+
(0, vitest_1.expect)(calls).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
(0, vitest_1.it)("still allows a deliberate unfiltered lookup", async () => {
|
|
73
|
+
const calls = stubSdkCall();
|
|
74
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
75
|
+
await clients.findOne({ filters: {} });
|
|
76
|
+
await clients.findOne({});
|
|
77
|
+
(0, vitest_1.expect)(calls).toHaveLength(2);
|
|
78
|
+
});
|
|
79
|
+
(0, vitest_1.it)("passes real values through untouched", async () => {
|
|
80
|
+
const calls = stubSdkCall();
|
|
81
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
82
|
+
await clients.findOne({ filters: { tokenFraisAdmin: "b69779ae-eeea" } });
|
|
83
|
+
(0, vitest_1.expect)(calls[0]?.params).toMatchObject({
|
|
84
|
+
filters: { tokenFraisAdmin: "b69779ae-eeea" },
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
(0, vitest_1.it)("allows null, which means the field is empty", async () => {
|
|
88
|
+
const calls = stubSdkCall();
|
|
89
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
90
|
+
await clients.findOne({ filters: { nom: { not: null } } });
|
|
91
|
+
(0, vitest_1.expect)(calls).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
(0, vitest_1.describe)("findAll", () => {
|
|
95
|
+
(0, vitest_1.it)("treats an undefined filter value as 'do not narrow on this'", async () => {
|
|
96
|
+
// Deliberately NOT guarded, unlike findOne. On a list query "no filter" is
|
|
97
|
+
// a coherent request, `filters: { x: maybeUndefined }` type-checks today,
|
|
98
|
+
// and this is what Prisma does with undefined in a `where`. The dangerous
|
|
99
|
+
// reading is findOne's, where an unnarrowed query returns an arbitrary row
|
|
100
|
+
// that the caller treats as a match.
|
|
101
|
+
const calls = stubSdkCall({ records: [], hasMore: false });
|
|
102
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
103
|
+
await clients.findAll({ filters: { tokenFraisAdmin: undefined } });
|
|
104
|
+
(0, vitest_1.expect)(calls).toHaveLength(1);
|
|
105
|
+
});
|
|
106
|
+
(0, vitest_1.it)("still allows listing a whole table on purpose", async () => {
|
|
107
|
+
const calls = stubSdkCall({ records: [], hasMore: false });
|
|
108
|
+
const clients = (0, index_js_1.createTableClient)("Clients");
|
|
109
|
+
await clients.findAll();
|
|
110
|
+
await clients.findAll({ limit: 10 });
|
|
111
|
+
(0, vitest_1.expect)(calls).toHaveLength(2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
(0, vitest_1.describe)("other clients on the same path", () => {
|
|
115
|
+
(0, vitest_1.it)("guards the Airtable client", async () => {
|
|
116
|
+
const calls = stubSdkCall();
|
|
117
|
+
const table = (0, index_js_1.createAirtableClient)("airtable", "Clients", {
|
|
118
|
+
baseId: "app1",
|
|
119
|
+
tableId: "tbl1",
|
|
120
|
+
});
|
|
121
|
+
const row = await table.findOne({
|
|
122
|
+
filters: { tokenFraisAdmin: undefined },
|
|
123
|
+
});
|
|
124
|
+
(0, vitest_1.expect)(row).toBeUndefined();
|
|
125
|
+
(0, vitest_1.expect)(calls).toHaveLength(0);
|
|
126
|
+
});
|
|
127
|
+
(0, vitest_1.it)("leaves auth user lookups alone, as a findAll", async () => {
|
|
128
|
+
const calls = stubSdkCall({ records: [], total: 0, hasMore: false });
|
|
129
|
+
const auth = (0, index_js_1.createAuthClient)();
|
|
130
|
+
await auth.findAllUsers({ filters: { email: undefined } });
|
|
131
|
+
(0, vitest_1.expect)(calls).toHaveLength(1);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/dist/cjs/sync/lib.js
CHANGED
|
@@ -541,6 +541,7 @@ function generateDbTs(inputSchema) {
|
|
|
541
541
|
lines.push('// `not: null` means "is set". An empty `in: []` matches nothing; an', "// empty `notIn: []` applies no filter.");
|
|
542
542
|
lines.push("// sort is currently IGNORED here — it is stripped before the request.", "// (it does work on zite.auth.findAllUsers). Order in SQL instead.");
|
|
543
543
|
lines.push("// limit defaults to 500, max 2000; offset is a row count (a number)");
|
|
544
|
+
lines.push('// a filter value of `undefined` is REJECTED (it would query unfiltered', '// and match an arbitrary record). Check the value first.');
|
|
544
545
|
lines.push("// .findOne({ id?, filters?, fields? }) → T | undefined");
|
|
545
546
|
lines.push("// .create({ record }) → T");
|
|
546
547
|
lines.push("// .update({ id, record }) → { id: string, fields: Partial<T> }");
|
|
@@ -1157,6 +1158,13 @@ function generateAirtableTs(inputLock) {
|
|
|
1157
1158
|
"// Auto-generated by zitejs generate from zite.lock. Do not edit manually.",
|
|
1158
1159
|
"// Airtable SDK — uses createAirtableClient from zitejs/runtime.",
|
|
1159
1160
|
"//",
|
|
1161
|
+
"// Usage in endpoint files (src/api/*.ts) — one client is exported per table:",
|
|
1162
|
+
`// import { ${lock.tables[0]?.sdkName ?? "TableName"} } from 'zitejs/integrations';`,
|
|
1163
|
+
"//",
|
|
1164
|
+
"// Always use 'zitejs/integrations' — never a relative path like",
|
|
1165
|
+
"// '../../.zite/integrations/airtable'. The tsconfig aliases resolve zitejs/*",
|
|
1166
|
+
"// imports to the correct .zite/ files.",
|
|
1167
|
+
"//",
|
|
1160
1168
|
"// Each exported table client has these methods (all take a single params object):",
|
|
1161
1169
|
"// findAll({ offset?, limit?, filters? }) => { records: T[], offset: string | undefined, hasMore: boolean }",
|
|
1162
1170
|
"// findOne({ id?, filters? }) => T | undefined",
|
|
@@ -1374,6 +1382,12 @@ function generateEmailSdk(integrationId) {
|
|
|
1374
1382
|
"// Auto-generated by zitejs generate from zite.config.json. Do not edit manually.",
|
|
1375
1383
|
"// Email SDK — sends through the Zite email gateway via the runtime bridge.",
|
|
1376
1384
|
"//",
|
|
1385
|
+
"// Usage in endpoint files (src/api/*.ts):",
|
|
1386
|
+
"// import { Email } from 'zitejs/email';",
|
|
1387
|
+
"//",
|
|
1388
|
+
"// Always use 'zitejs/email' — never a relative path like '../../.zite/integrations/email'.",
|
|
1389
|
+
"// The tsconfig aliases resolve zitejs/* imports to the correct .zite/ files.",
|
|
1390
|
+
"//",
|
|
1377
1391
|
"// await Email.send({ to, subject, body: [{ type: 'text', content: '...' }] })",
|
|
1378
1392
|
"// => { success: boolean; messageId: string }",
|
|
1379
1393
|
"//",
|
|
@@ -25,7 +25,8 @@ export declare function toPascalCase(name: string): string;
|
|
|
25
25
|
export declare function toCamelCase(name: string): string;
|
|
26
26
|
/**
|
|
27
27
|
* camelCase for a FRESH sdkName, reading acronym runs as words: "VIP" ->
|
|
28
|
-
* "vip", "APIKey" -> "apiKey" (plain toCamelCase yields "vIP" / "aPIKey")
|
|
28
|
+
* "vip", "APIKey" -> "apiKey" (plain toCamelCase yields "vIP" / "aPIKey"), and
|
|
29
|
+
* folding a diacritic into its base letter: "Filières" -> "filieres".
|
|
29
30
|
*
|
|
30
31
|
* Deliberately not folded into toCamelCase: that also derives endpoint
|
|
31
32
|
* identifiers from existing filenames on every generate, where normalizing
|
|
@@ -50,7 +50,8 @@ function toCamelCase(name) {
|
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* camelCase for a FRESH sdkName, reading acronym runs as words: "VIP" ->
|
|
53
|
-
* "vip", "APIKey" -> "apiKey" (plain toCamelCase yields "vIP" / "aPIKey")
|
|
53
|
+
* "vip", "APIKey" -> "apiKey" (plain toCamelCase yields "vIP" / "aPIKey"), and
|
|
54
|
+
* folding a diacritic into its base letter: "Filières" -> "filieres".
|
|
54
55
|
*
|
|
55
56
|
* Deliberately not folded into toCamelCase: that also derives endpoint
|
|
56
57
|
* identifiers from existing filenames on every generate, where normalizing
|
|
@@ -58,7 +59,22 @@ function toCamelCase(name) {
|
|
|
58
59
|
* chosen for the first time — generateSchema preserves existing sdkNames.
|
|
59
60
|
*/
|
|
60
61
|
function toSdkName(name) {
|
|
62
|
+
// Decompose, then drop the combining marks, so an accented letter keeps its
|
|
63
|
+
// base rather than being read as punctuation. `toPascalCase` treats anything
|
|
64
|
+
// outside [a-zA-Z0-9] as a word separator and uppercases what follows it, so
|
|
65
|
+
// without this a table called "Fiches Filières" allocates `fichesFiliRes`.
|
|
66
|
+
//
|
|
67
|
+
// The bar is not "looks nicer" — it is Zite 1.0, which sanitised names this
|
|
68
|
+
// exact way (`getIntegrationNameMapping`) and therefore allocated
|
|
69
|
+
// `fichesFilieres`. Every app carried over from 1.0 compiles against that
|
|
70
|
+
// name, so a generator that disagrees hands a non-English base an accessor
|
|
71
|
+
// nobody would write and nothing resolves to.
|
|
72
|
+
//
|
|
73
|
+
// Only Latin diacritics fold. A name with no ASCII letters at all (Cyrillic,
|
|
74
|
+
// CJK, emoji) still collapses to `_` here, exactly as it did in 1.0.
|
|
61
75
|
const deAcronymed = name
|
|
76
|
+
.normalize("NFD")
|
|
77
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
62
78
|
.replace(/([A-Z]+)([A-Z][a-z])/g, (_, run, next) => run.charAt(0) + run.slice(1).toLowerCase() + next)
|
|
63
79
|
.replace(/([A-Z])([A-Z]+)/g, (_, first, rest) => first + rest.toLowerCase());
|
|
64
80
|
return toCamelCase(deAcronymed);
|
|
@@ -235,3 +235,51 @@ const recordTypeKeys = (source, typeName) => {
|
|
|
235
235
|
(0, vitest_1.expect)(schema.tables[0].name).toBe("Deals");
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
|
+
/**
|
|
239
|
+
* `toPascalCase` reads anything outside [a-zA-Z0-9] as a word separator and
|
|
240
|
+
* uppercases what follows, so an accent used to split the word it sat inside:
|
|
241
|
+
* "Filières" -> `filiRes`. Zite 1.0 stripped the combining mark instead, and
|
|
242
|
+
* every app carried over from it compiles against THAT name — so this is a
|
|
243
|
+
* disagreement with the apps in the wild, not a cosmetic preference.
|
|
244
|
+
*/
|
|
245
|
+
(0, vitest_1.describe)("diacritics in a fresh sdkName", () => {
|
|
246
|
+
const nameFor = (tableName) => (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: tableName, fields: [] }]))
|
|
247
|
+
.tables[0].sdkName;
|
|
248
|
+
vitest_1.it.each([
|
|
249
|
+
["Fiches Filières", "fichesFilieres"],
|
|
250
|
+
["Recherches Métiers", "recherchesMetiers"],
|
|
251
|
+
["Profil Compétences", "profilCompetences"],
|
|
252
|
+
["Café Menu", "cafeMenu"],
|
|
253
|
+
["Señor Clients", "senorClients"],
|
|
254
|
+
// Real, from a migrated base: an all-caps name where the accent sits inside
|
|
255
|
+
// an acronym run, so the de-acronym pass has to see the folded letter.
|
|
256
|
+
["XICOH TRÁMITES", "xicohTramites"],
|
|
257
|
+
])("folds %s into %s", (display, sdkName) => {
|
|
258
|
+
(0, vitest_1.expect)(nameFor(display)).toBe(sdkName);
|
|
259
|
+
});
|
|
260
|
+
(0, vitest_1.it)("folds a field name the same way", () => {
|
|
261
|
+
const schema = (0, lib_js_1.generateSchema)(database([
|
|
262
|
+
{
|
|
263
|
+
id: "tbl_1",
|
|
264
|
+
name: "Trámites",
|
|
265
|
+
fields: [field("fld_1", "Horario de Atención")],
|
|
266
|
+
},
|
|
267
|
+
]));
|
|
268
|
+
(0, vitest_1.expect)(schema.tables[0].fields[0].sdkName).toBe("horarioDeAtencion");
|
|
269
|
+
});
|
|
270
|
+
(0, vitest_1.it)("leaves a name with no ASCII letters degenerate, as 1.0 did", () => {
|
|
271
|
+
// NFD only folds Latin diacritics. Nothing here to fold onto, so this
|
|
272
|
+
// collapses exactly as before — called out so the narrower scope is a
|
|
273
|
+
// decision on the record rather than an oversight.
|
|
274
|
+
(0, vitest_1.expect)(nameFor("Посещения")).toBe("_");
|
|
275
|
+
});
|
|
276
|
+
(0, vitest_1.it)("does not rename a table already locked to the old spelling", () => {
|
|
277
|
+
// The whole point of the lock: an app compiling against `fichesFiliRes`
|
|
278
|
+
// keeps it. Only names allocated from here on change.
|
|
279
|
+
const existingSchema = {
|
|
280
|
+
tables: [{ id: "tbl_1", sdkName: "fichesFiliRes", fields: [] }],
|
|
281
|
+
};
|
|
282
|
+
const schema = (0, lib_js_1.generateSchema)(database([{ id: "tbl_1", name: "Fiches Filières", fields: [] }]), existingSchema);
|
|
283
|
+
(0, vitest_1.expect)(schema.tables[0].sdkName).toBe("fichesFiliRes");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createCaller } from '../caller/index.js';
|
|
@@ -82,6 +82,12 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
|
|
|
82
82
|
* `{ user: null }` for a webhook fire exactly as it does for a cron one.
|
|
83
83
|
*/
|
|
84
84
|
webhook?: TWebhook;
|
|
85
|
+
/**
|
|
86
|
+
* Set by {@link createEndpoint}, not by hand. The worker validates
|
|
87
|
+
* `inputSchema` for bundles built before this did, and skips ones that
|
|
88
|
+
* declare it — parsing twice would apply a schema's transforms twice.
|
|
89
|
+
*/
|
|
90
|
+
validatesInput?: boolean;
|
|
85
91
|
execute: (params: {
|
|
86
92
|
input: TInput;
|
|
87
93
|
context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
|
|
@@ -89,4 +95,11 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
|
|
|
89
95
|
stream: ZiteStreamInterface;
|
|
90
96
|
} : {})) => Promise<TOutput> | TOutput;
|
|
91
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Create an endpoint definition, parsing `input` against `inputSchema` before
|
|
100
|
+
* `execute` runs. Nothing downstream parses it — the worker calls `execute` on
|
|
101
|
+
* whatever this returns — so while this was a bare `return config` a required
|
|
102
|
+
* field could be missing and the endpoint ran anyway. Parsing here is also what
|
|
103
|
+
* makes `TRawInput` -> `TInput` (defaults, transforms) true at runtime.
|
|
104
|
+
*/
|
|
92
105
|
export declare function createEndpoint<TInput = unknown, TOutput = unknown, TStream extends boolean = false, TSchedule extends ZiteSchedule | undefined = undefined, TWebhook extends ZiteWebhook | undefined = undefined, TRawInput = TInput>(config: EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>): EndpointConfig<TInput, TOutput, TStream, TSchedule, TWebhook, TRawInput>;
|
|
@@ -26,6 +26,68 @@ export class ZiteError extends Error {
|
|
|
26
26
|
this.name = "ZiteError";
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
/** Injected by the runner on a platform-fired run — see `isPlatformTriggeredInput`. */
|
|
30
|
+
const PLATFORM_TRIGGER_KEYS = ["__cron", "__webhook"];
|
|
31
|
+
function isPlatformTriggeredInput(input) {
|
|
32
|
+
if (typeof input !== "object" || input === null)
|
|
33
|
+
return false;
|
|
34
|
+
return PLATFORM_TRIGGER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(input, key));
|
|
35
|
+
}
|
|
36
|
+
function isIssueBearingError(error) {
|
|
37
|
+
return (typeof error === "object" &&
|
|
38
|
+
error !== null &&
|
|
39
|
+
Array.isArray(error.issues));
|
|
40
|
+
}
|
|
41
|
+
function describeIssues(error) {
|
|
42
|
+
return error.issues
|
|
43
|
+
.map((issue) => {
|
|
44
|
+
const path = issue.path.map((part) => String(part)).join(".");
|
|
45
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
46
|
+
})
|
|
47
|
+
.join("; ");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create an endpoint definition, parsing `input` against `inputSchema` before
|
|
51
|
+
* `execute` runs. Nothing downstream parses it — the worker calls `execute` on
|
|
52
|
+
* whatever this returns — so while this was a bare `return config` a required
|
|
53
|
+
* field could be missing and the endpoint ran anyway. Parsing here is also what
|
|
54
|
+
* makes `TRawInput` -> `TInput` (defaults, transforms) true at runtime.
|
|
55
|
+
*/
|
|
29
56
|
export function createEndpoint(config) {
|
|
30
|
-
|
|
57
|
+
const { inputSchema, execute } = config;
|
|
58
|
+
const firesWithoutARequest = Boolean(config.schedule || config.webhook);
|
|
59
|
+
// An `inputSchema` that isn't a validator stays inert, as it was.
|
|
60
|
+
if (!inputSchema || typeof inputSchema.parse !== "function")
|
|
61
|
+
return config;
|
|
62
|
+
return {
|
|
63
|
+
...config,
|
|
64
|
+
validatesInput: true,
|
|
65
|
+
// `async` so a schema failure rejects: `execute` may be declared sync, and
|
|
66
|
+
// a sync throw would escape the caller's `.catch()`.
|
|
67
|
+
execute: async (params) => {
|
|
68
|
+
// Cron and webhook fires carry no request body, so a schema written for
|
|
69
|
+
// request callers doesn't describe them.
|
|
70
|
+
if (isPlatformTriggeredInput(params.input))
|
|
71
|
+
return execute(params);
|
|
72
|
+
// Same, for a fire with no input object to hold the sentinel: the runner
|
|
73
|
+
// only merges `__cron` into a plain object.
|
|
74
|
+
if (firesWithoutARequest && params.input === undefined) {
|
|
75
|
+
return execute(params);
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = inputSchema.parse(params.input);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new ZiteError({
|
|
83
|
+
code: "BAD_REQUEST",
|
|
84
|
+
message: isIssueBearingError(error)
|
|
85
|
+
? `Invalid input: ${describeIssues(error)}`
|
|
86
|
+
: `Invalid input: ${String(error?.message ?? error)}`,
|
|
87
|
+
userFacingMessage: "Some of the information sent with this request was missing or invalid.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return execute({ ...params, input: parsed });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
31
93
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createEndpoint, ZiteError } from "./index.js";
|
|
4
|
+
import { createTableClient } from "../runtime/index.js";
|
|
5
|
+
const requestContext = { user: { id: "u1", email: "a@b.c" } };
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
describe("createEndpoint input validation", () => {
|
|
10
|
+
it("rejects input that does not satisfy inputSchema, without running execute", async () => {
|
|
11
|
+
const execute = vi.fn();
|
|
12
|
+
const endpoint = createEndpoint({
|
|
13
|
+
inputSchema: z.object({ token: z.string().min(10) }),
|
|
14
|
+
execute,
|
|
15
|
+
});
|
|
16
|
+
await expect(endpoint.execute({ input: {}, context: requestContext })).rejects.toBeInstanceOf(ZiteError);
|
|
17
|
+
expect(execute).not.toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
it("surfaces the failure as BAD_REQUEST and names the offending field", async () => {
|
|
20
|
+
const endpoint = createEndpoint({
|
|
21
|
+
inputSchema: z.object({ token: z.string().min(10) }),
|
|
22
|
+
execute: async () => "unreachable",
|
|
23
|
+
});
|
|
24
|
+
await expect(endpoint.execute({ input: {}, context: requestContext })).rejects.toMatchObject({
|
|
25
|
+
code: "BAD_REQUEST",
|
|
26
|
+
message: expect.stringContaining("token"),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
it("rejects a call with no input at all", async () => {
|
|
30
|
+
const execute = vi.fn();
|
|
31
|
+
const endpoint = createEndpoint({
|
|
32
|
+
inputSchema: z.object({ token: z.string().min(10) }),
|
|
33
|
+
execute,
|
|
34
|
+
});
|
|
35
|
+
await expect(endpoint.execute({
|
|
36
|
+
input: undefined,
|
|
37
|
+
context: requestContext,
|
|
38
|
+
})).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
39
|
+
expect(execute).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
it("hands execute the parsed value, so schema defaults finally apply", async () => {
|
|
42
|
+
const endpoint = createEndpoint({
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
token: z.string(),
|
|
45
|
+
limit: z.number().default(10),
|
|
46
|
+
}),
|
|
47
|
+
execute: async ({ input }) => input,
|
|
48
|
+
});
|
|
49
|
+
await expect(endpoint.execute({
|
|
50
|
+
input: { token: "abc" },
|
|
51
|
+
context: requestContext,
|
|
52
|
+
})).resolves.toEqual({ token: "abc", limit: 10 });
|
|
53
|
+
});
|
|
54
|
+
it("passes valid input through untouched", async () => {
|
|
55
|
+
const endpoint = createEndpoint({
|
|
56
|
+
inputSchema: z.object({
|
|
57
|
+
token: z.string().min(10),
|
|
58
|
+
action: z.enum(["load", "accept"]),
|
|
59
|
+
}),
|
|
60
|
+
execute: async ({ input }) => input.action,
|
|
61
|
+
});
|
|
62
|
+
await expect(endpoint.execute({
|
|
63
|
+
input: { token: "b69779ae-eeea", action: "load" },
|
|
64
|
+
context: requestContext,
|
|
65
|
+
})).resolves.toBe("load");
|
|
66
|
+
});
|
|
67
|
+
it("declares that it validates, so the worker does not parse a second time", () => {
|
|
68
|
+
const withSchema = createEndpoint({
|
|
69
|
+
inputSchema: z.object({ token: z.string() }),
|
|
70
|
+
execute: async () => null,
|
|
71
|
+
});
|
|
72
|
+
const withoutSchema = createEndpoint({ execute: async () => null });
|
|
73
|
+
expect(withSchema.validatesInput).toBe(true);
|
|
74
|
+
expect(withoutSchema.validatesInput).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
it("leaves an endpoint without an inputSchema exactly as it was", async () => {
|
|
77
|
+
const endpoint = createEndpoint({
|
|
78
|
+
execute: async ({ input }) => input,
|
|
79
|
+
});
|
|
80
|
+
await expect(endpoint.execute({
|
|
81
|
+
input: { anything: true },
|
|
82
|
+
context: requestContext,
|
|
83
|
+
})).resolves.toEqual({ anything: true });
|
|
84
|
+
});
|
|
85
|
+
it("preserves the stream handle for streaming endpoints", async () => {
|
|
86
|
+
const stream = { write: async () => { }, forward: async () => "" };
|
|
87
|
+
const endpoint = createEndpoint({
|
|
88
|
+
stream: true,
|
|
89
|
+
inputSchema: z.object({ prompt: z.string() }),
|
|
90
|
+
execute: async (params) => params.stream === stream,
|
|
91
|
+
});
|
|
92
|
+
await expect(endpoint.execute({
|
|
93
|
+
input: { prompt: "hi" },
|
|
94
|
+
context: requestContext,
|
|
95
|
+
stream,
|
|
96
|
+
})).resolves.toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe("platform-triggered runs", () => {
|
|
100
|
+
it("does not validate a cron fire, and leaves __cron readable", async () => {
|
|
101
|
+
const endpoint = createEndpoint({
|
|
102
|
+
inputSchema: z.object({ token: z.string().min(10) }),
|
|
103
|
+
schedule: {
|
|
104
|
+
scheduleType: "recurring",
|
|
105
|
+
schedule: { frequency: "hourly", interval: 6 },
|
|
106
|
+
timezone: "UTC",
|
|
107
|
+
},
|
|
108
|
+
execute: async ({ input }) => input,
|
|
109
|
+
});
|
|
110
|
+
await expect(endpoint.execute({
|
|
111
|
+
input: { __cron: { scheduleId: "s1" } },
|
|
112
|
+
context: { user: null },
|
|
113
|
+
})).resolves.toEqual({ __cron: { scheduleId: "s1" } });
|
|
114
|
+
});
|
|
115
|
+
it("does not validate a scheduled fire that carries no input object", async () => {
|
|
116
|
+
const endpoint = createEndpoint({
|
|
117
|
+
inputSchema: z.string(),
|
|
118
|
+
schedule: {
|
|
119
|
+
scheduleType: "recurring",
|
|
120
|
+
schedule: { frequency: "hourly", interval: 6 },
|
|
121
|
+
timezone: "UTC",
|
|
122
|
+
},
|
|
123
|
+
execute: async ({ input }) => input ?? "(no input)",
|
|
124
|
+
});
|
|
125
|
+
await expect(endpoint.execute({ input: undefined, context: { user: null } })).resolves.toBe("(no input)");
|
|
126
|
+
});
|
|
127
|
+
it("still validates an ordinary endpoint called with no input", async () => {
|
|
128
|
+
const endpoint = createEndpoint({
|
|
129
|
+
inputSchema: z.string(),
|
|
130
|
+
execute: async ({ input }) => input,
|
|
131
|
+
});
|
|
132
|
+
await expect(endpoint.execute({ input: undefined, context: requestContext })).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
133
|
+
});
|
|
134
|
+
it("does not validate a webhook fire", async () => {
|
|
135
|
+
const endpoint = createEndpoint({
|
|
136
|
+
inputSchema: z.object({ token: z.string().min(10) }),
|
|
137
|
+
webhook: {},
|
|
138
|
+
execute: async ({ input }) => input,
|
|
139
|
+
});
|
|
140
|
+
await expect(endpoint.execute({
|
|
141
|
+
input: { __webhook: { headers: {} }, id: 7 },
|
|
142
|
+
context: { user: null },
|
|
143
|
+
})).resolves.toEqual({ __webhook: { headers: {} }, id: 7 });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
/**
|
|
147
|
+
* The failure this was written for: an endpoint that looks a customer up by a
|
|
148
|
+
* token, called with no token. The undefined was dropped on the way to the
|
|
149
|
+
* database and the unfiltered lookup returned the first row of the table.
|
|
150
|
+
* Either guard alone stops it; they fail at different layers, so both are here.
|
|
151
|
+
*/
|
|
152
|
+
describe("regression: token lookup on an endpoint called without a token", () => {
|
|
153
|
+
function buildEndpoint(onMatch) {
|
|
154
|
+
const clients = createTableClient("Clients");
|
|
155
|
+
return createEndpoint({
|
|
156
|
+
inputSchema: z.object({
|
|
157
|
+
token: z.string().min(10),
|
|
158
|
+
action: z.enum(["load", "accept"]),
|
|
159
|
+
}),
|
|
160
|
+
execute: async ({ input }) => {
|
|
161
|
+
const row = await clients.findOne({ filters: { token: input.token } });
|
|
162
|
+
if (!row)
|
|
163
|
+
return "invalid_token";
|
|
164
|
+
onMatch(row.nom);
|
|
165
|
+
return "ok";
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
it("stops at the schema, so the database is never reached", async () => {
|
|
170
|
+
const dispatched = [];
|
|
171
|
+
vi.stubGlobal("__wrapSdkCall", async (_i, _c, m) => {
|
|
172
|
+
dispatched.push(m);
|
|
173
|
+
return { id: "1", token: "", nom: "an unrelated customer" };
|
|
174
|
+
});
|
|
175
|
+
const matched = [];
|
|
176
|
+
await expect(buildEndpoint((nom) => matched.push(nom)).execute({
|
|
177
|
+
input: {},
|
|
178
|
+
context: requestContext,
|
|
179
|
+
})).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
|
180
|
+
expect(dispatched).toEqual([]);
|
|
181
|
+
expect(matched).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
it("stops at the query too, if an undefined token reaches it another way", async () => {
|
|
184
|
+
const dispatched = [];
|
|
185
|
+
vi.stubGlobal("__wrapSdkCall", async (_i, _c, m) => {
|
|
186
|
+
dispatched.push(m);
|
|
187
|
+
return { id: "1", token: "", nom: "an unrelated customer" };
|
|
188
|
+
});
|
|
189
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => { });
|
|
190
|
+
const clients = createTableClient("Clients");
|
|
191
|
+
const token = undefined;
|
|
192
|
+
// Answers "no match" rather than throwing: `if (!row) return
|
|
193
|
+
// { status: 'invalid_token' }` is what the endpoint already does, so this
|
|
194
|
+
// makes the incident path correct without the app changing anything.
|
|
195
|
+
const row = await clients.findOne({ filters: { token } });
|
|
196
|
+
expect(row).toBeUndefined();
|
|
197
|
+
expect(dispatched).toEqual([]);
|
|
198
|
+
expect(warn).toHaveBeenCalled();
|
|
199
|
+
warn.mockRestore();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx zitejs bundle` — Bundle endpoint files for cloudflare-lambda deployment.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the legacy `scripts/bundle-endpoints.js` from zitejs-starter.
|
|
5
|
+
* Reads endpoints from src/api/*.ts, resolves SDK imports via .zite/backend.ts
|
|
6
|
+
* (monorepo) or src/__zite__/integrations.ts (legacy), and outputs bundled ESM
|
|
7
|
+
* code suitable for the cloudflare-lambda worker runtime.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx zitejs bundle # bundle all endpoints
|
|
11
|
+
* npx zitejs bundle --app admin-panel # bundle endpoints for a specific app
|
|
12
|
+
* npx zitejs bundle --script <path> # bundle a one-off script
|
|
13
|
+
*
|
|
14
|
+
* Output: JSON to stdout
|
|
15
|
+
* { bundledEndpoints: Record<string, string>, endpointErrors?: Record<string, string> }
|
|
16
|
+
* or for --script: { bundledCode: string } or { error: string }
|
|
17
|
+
*
|
|
18
|
+
* Dependencies (add to zitejs package.json):
|
|
19
|
+
* "esbuild": "^0.25.0"
|
|
20
|
+
* "@babel/parser": "^7.26.0"
|
|
21
|
+
*/
|
|
22
|
+
import * as esbuild from 'esbuild';
|
|
23
|
+
export declare const UNSUPPORTED_IN_WORKERD: Set<string>;
|
|
24
|
+
export declare const BASE_BUILD_OPTIONS: esbuild.BuildOptions;
|
|
1
25
|
export declare function runBundle(): Promise<void>;
|
package/dist/esm/bundle/index.js
CHANGED
|
@@ -23,14 +23,21 @@ import * as esbuild from 'esbuild';
|
|
|
23
23
|
import * as path from 'path';
|
|
24
24
|
import * as fs from 'fs';
|
|
25
25
|
import { parse } from '@babel/parser';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
import { builtinModules } from 'node:module';
|
|
27
|
+
// workerd's `nodejs_compat` does not provide these. Left external they bundle
|
|
28
|
+
// fine and then kill the worker at load time with `No such module`; excluded,
|
|
29
|
+
// esbuild fails the one endpoint with a resolvable error instead. Pinned by
|
|
30
|
+
// apps/cloudflare-lambda tests/sdk-compat/node-builtin-support.test.ts.
|
|
31
|
+
export const UNSUPPORTED_IN_WORKERD = new Set([
|
|
32
|
+
'child_process', 'dgram', 'inspector', 'inspector/promises', 'perf_hooks',
|
|
33
|
+
'readline', 'readline/promises', 'repl', 'tty', 'v8', 'worker_threads',
|
|
34
|
+
]);
|
|
35
|
+
// External, not bundled: the worker supplies them. Sourced from Node because
|
|
36
|
+
// the hand-written list covered 36 of 68, so a dependency reaching for a name
|
|
37
|
+
// we forgot (`constants`, `fs/promises`) failed the whole endpoint.
|
|
38
|
+
const NODE_BUILTINS = builtinModules
|
|
39
|
+
.filter(m => !m.startsWith('_') && !UNSUPPORTED_IN_WORKERD.has(m))
|
|
40
|
+
.flatMap(m => [m, `node:${m}`]);
|
|
34
41
|
const PREBUNDLED_LIBS = {
|
|
35
42
|
'@zite/endpoints-runtime-sdk': '__zite-runtime__.js',
|
|
36
43
|
'zod': '__zod__.js',
|
|
@@ -51,7 +58,7 @@ const PREBUNDLED_LIBS = {
|
|
|
51
58
|
'@google/generative-ai': '__gemini__.js',
|
|
52
59
|
'@elevenlabs/elevenlabs-js': '__elevenlabs__.js',
|
|
53
60
|
};
|
|
54
|
-
const BASE_BUILD_OPTIONS = {
|
|
61
|
+
export const BASE_BUILD_OPTIONS = {
|
|
55
62
|
bundle: true,
|
|
56
63
|
write: false,
|
|
57
64
|
format: 'esm',
|
package/dist/esm/cli.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createTableClient } from '../runtime/index.js';
|