zitejs 0.9.136 → 0.9.138

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/README.md CHANGED
@@ -23,10 +23,15 @@ const result = await zite.sql({
23
23
  query: 'SELECT "id", "email" FROM "ziteUsers"',
24
24
  });
25
25
 
26
- // Define API endpoints
26
+ // Define API endpoints. step.run saves each result as it completes, so a
27
+ // failed run resumed from run history skips the steps that already ran.
27
28
  import { createEndpoint, ZiteError } from 'zitejs/api';
28
29
  export default createEndpoint({
29
- execute: async ({ input }) => { ... },
30
+ execute: async ({ input, step }) => {
31
+ const order = await step.run('load-order', () => loadOrder(input.id));
32
+ await step.run('charge', () => charge(order));
33
+ return { charged: true };
34
+ },
30
35
  });
31
36
 
32
37
  // Authentication
@@ -216,6 +216,14 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
216
216
  validatesInput?: boolean;
217
217
  execute: (params: {
218
218
  input: TInput;
219
+ /**
220
+ * Durable steps. `step.run(name, fn)` runs `fn` once per logical run:
221
+ * its result is saved before anything after it executes, and when a
222
+ * failed run is resumed, completed steps return their saved results
223
+ * instead of re-running their side effects. Names must be unique within
224
+ * a run, and results must be plain JSON data under 256KB.
225
+ */
226
+ step: ZiteStep;
219
227
  context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
220
228
  } & (TStream extends true ? {
221
229
  stream: ZiteStreamInterface;
@@ -223,6 +231,17 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
223
231
  trigger?: ZiteTriggerContext<TTrigger>;
224
232
  } : {})) => Promise<TOutput> | TOutput;
225
233
  }
234
+ /**
235
+ * Durable steps, provided by the platform inside `execute`. Wrap each unit of
236
+ * work with side effects — a provider call, a message send, a batch write —
237
+ * so a retry of a failed run resumes at the failure instead of redoing
238
+ * completed work. Steps run at least once — a crash between the side effect
239
+ * and the save re-runs the step — so make a step idempotent where repeating
240
+ * it would matter, and give a step inside a loop the item's id in its name.
241
+ */
242
+ export interface ZiteStep {
243
+ run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
244
+ }
226
245
  /**
227
246
  * Create an endpoint definition, parsing `input` against `inputSchema` before
228
247
  * `execute` runs. Nothing downstream parses it — the worker calls `execute` on
@@ -5,6 +5,10 @@ const zod_1 = require("zod");
5
5
  const index_js_1 = require("./index.js");
6
6
  const index_js_2 = require("../runtime/index.js");
7
7
  const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
8
+ // Direct execute() calls need the step surface the runtime always provides.
9
+ const stepStub = {
10
+ run: async (_name, fn) => await fn(),
11
+ };
8
12
  (0, vitest_1.afterEach)(() => {
9
13
  vitest_1.vi.unstubAllGlobals();
10
14
  });
@@ -15,7 +19,11 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
15
19
  inputSchema: zod_1.z.object({ token: zod_1.z.string().min(10) }),
16
20
  execute,
17
21
  });
18
- await (0, vitest_1.expect)(endpoint.execute({ input: {}, context: requestContext })).rejects.toBeInstanceOf(index_js_1.ZiteError);
22
+ await (0, vitest_1.expect)(endpoint.execute({
23
+ input: {},
24
+ context: requestContext,
25
+ step: stepStub,
26
+ })).rejects.toBeInstanceOf(index_js_1.ZiteError);
19
27
  (0, vitest_1.expect)(execute).not.toHaveBeenCalled();
20
28
  });
21
29
  (0, vitest_1.it)("surfaces the failure as BAD_REQUEST and names the offending field", async () => {
@@ -23,7 +31,11 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
23
31
  inputSchema: zod_1.z.object({ token: zod_1.z.string().min(10) }),
24
32
  execute: async () => "unreachable",
25
33
  });
26
- await (0, vitest_1.expect)(endpoint.execute({ input: {}, context: requestContext })).rejects.toMatchObject({
34
+ await (0, vitest_1.expect)(endpoint.execute({
35
+ input: {},
36
+ context: requestContext,
37
+ step: stepStub,
38
+ })).rejects.toMatchObject({
27
39
  code: "BAD_REQUEST",
28
40
  message: vitest_1.expect.stringContaining("token"),
29
41
  });
@@ -35,6 +47,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
35
47
  execute,
36
48
  });
37
49
  await (0, vitest_1.expect)(endpoint.execute({
50
+ step: stepStub,
38
51
  input: undefined,
39
52
  context: requestContext,
40
53
  })).rejects.toMatchObject({ code: "BAD_REQUEST" });
@@ -49,6 +62,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
49
62
  execute: async ({ input }) => input,
50
63
  });
51
64
  await (0, vitest_1.expect)(endpoint.execute({
65
+ step: stepStub,
52
66
  input: { token: "abc" },
53
67
  context: requestContext,
54
68
  })).resolves.toEqual({ token: "abc", limit: 10 });
@@ -62,6 +76,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
62
76
  execute: async ({ input }) => input.action,
63
77
  });
64
78
  await (0, vitest_1.expect)(endpoint.execute({
79
+ step: stepStub,
65
80
  input: { token: "b69779ae-eeea", action: "load" },
66
81
  context: requestContext,
67
82
  })).resolves.toBe("load");
@@ -80,6 +95,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
80
95
  execute: async ({ input }) => input,
81
96
  });
82
97
  await (0, vitest_1.expect)(endpoint.execute({
98
+ step: stepStub,
83
99
  input: { anything: true },
84
100
  context: requestContext,
85
101
  })).resolves.toEqual({ anything: true });
@@ -92,6 +108,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
92
108
  execute: async (params) => params.stream === stream,
93
109
  });
94
110
  await (0, vitest_1.expect)(endpoint.execute({
111
+ step: stepStub,
95
112
  input: { prompt: "hi" },
96
113
  context: requestContext,
97
114
  stream,
@@ -110,6 +127,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
110
127
  execute: async ({ input }) => input,
111
128
  });
112
129
  await (0, vitest_1.expect)(endpoint.execute({
130
+ step: stepStub,
113
131
  input: { __cron: { scheduleId: "s1" } },
114
132
  context: { user: null },
115
133
  })).resolves.toEqual({ __cron: { scheduleId: "s1" } });
@@ -124,14 +142,22 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
124
142
  },
125
143
  execute: async ({ input }) => input ?? "(no input)",
126
144
  });
127
- await (0, vitest_1.expect)(endpoint.execute({ input: undefined, context: { user: null } })).resolves.toBe("(no input)");
145
+ await (0, vitest_1.expect)(endpoint.execute({
146
+ input: undefined,
147
+ context: { user: null },
148
+ step: stepStub,
149
+ })).resolves.toBe("(no input)");
128
150
  });
129
151
  (0, vitest_1.it)("still validates an ordinary endpoint called with no input", async () => {
130
152
  const endpoint = (0, index_js_1.createEndpoint)({
131
153
  inputSchema: zod_1.z.string(),
132
154
  execute: async ({ input }) => input,
133
155
  });
134
- await (0, vitest_1.expect)(endpoint.execute({ input: undefined, context: requestContext })).rejects.toMatchObject({ code: "BAD_REQUEST" });
156
+ await (0, vitest_1.expect)(endpoint.execute({
157
+ input: undefined,
158
+ context: requestContext,
159
+ step: stepStub,
160
+ })).rejects.toMatchObject({ code: "BAD_REQUEST" });
135
161
  });
136
162
  (0, vitest_1.it)("hands a poll a typed trigger and skips the input schema for it", async () => {
137
163
  const handled = [];
@@ -150,6 +176,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
150
176
  });
151
177
  const seen = new Set(["a"]);
152
178
  await (0, vitest_1.expect)(endpoint.execute({
179
+ step: stepStub,
153
180
  input: {},
154
181
  context: { user: null },
155
182
  trigger: {
@@ -218,6 +245,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
218
245
  }),
219
246
  });
220
247
  await (0, vitest_1.expect)(endpoint.execute({
248
+ step: stepStub,
221
249
  input: { kind: "sync" },
222
250
  context: { user: null },
223
251
  trigger: {
@@ -228,7 +256,11 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
228
256
  headers: {},
229
257
  deliveryId: "chan:2",
230
258
  },
231
- })).resolves.toEqual({ body: { kind: "sync" }, cursor: "sync1", delivery: "chan:2" });
259
+ })).resolves.toEqual({
260
+ body: { kind: "sync" },
261
+ cursor: "sync1",
262
+ delivery: "chan:2",
263
+ });
232
264
  });
233
265
  (0, vitest_1.it)("refuses events on a trigger that also polls", () => {
234
266
  (0, index_js_1.createEndpoint)({
@@ -245,6 +277,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
245
277
  execute: async ({ input }) => input,
246
278
  });
247
279
  await (0, vitest_1.expect)(endpoint.execute({
280
+ step: stepStub,
248
281
  input: { __webhook: { headers: {} }, id: 7 },
249
282
  context: { user: null },
250
283
  })).resolves.toEqual({ __webhook: { headers: {} }, id: 7 });
@@ -283,6 +316,7 @@ const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
283
316
  await (0, vitest_1.expect)(buildEndpoint((nom) => matched.push(nom)).execute({
284
317
  input: {},
285
318
  context: requestContext,
319
+ step: stepStub,
286
320
  })).rejects.toMatchObject({ code: "BAD_REQUEST" });
287
321
  (0, vitest_1.expect)(dispatched).toEqual([]);
288
322
  (0, vitest_1.expect)(matched).toEqual([]);
@@ -178,6 +178,13 @@ async function runGenerate() {
178
178
  // The backend writes the schema/lock files to the sandbox before
179
179
  // calling this. Use `zitejs sync` separately for local dev (fetches
180
180
  // metadata from the backend API).
181
+ // Everything below reads relative paths, so run from an app dir this would
182
+ // find no source roots, write nothing, and still print "Done!".
183
+ const workspaceRoot = (0, sourceRoots_js_1.findWorkspaceRoot)();
184
+ if (workspaceRoot && workspaceRoot !== (0, path_1.resolve)(".")) {
185
+ console.log(`Running from the workspace root: ${workspaceRoot}`);
186
+ process.chdir(workspaceRoot);
187
+ }
181
188
  // 1. Generate root .zite/db.ts from zite.schema.json
182
189
  try {
183
190
  if ((0, fs_2.existsSync)("zite.schema.json")) {
@@ -15,6 +15,13 @@ export interface SourceDir {
15
15
  }
16
16
  /** Every source dir under every root, in root order then name order. */
17
17
  export declare function listSourceDirs(cwd?: string): SourceDir[];
18
+ /**
19
+ * The workspace root at or above `cwd`: the nearest directory holding a
20
+ * `zite.config.json` next to an `apps/` or `automations/` dir. An app dir has
21
+ * a config of its own but no source root under it, so it never matches.
22
+ * Undefined outside a monorepo (a single-app project runs where it is).
23
+ */
24
+ export declare function findWorkspaceRoot(cwd?: string): string | undefined;
18
25
  /** The source dir with this bare name, under whichever root holds it. */
19
26
  export declare function findSourceDir(name: string, cwd?: string): SourceDir | undefined;
20
27
  /**
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AUTOMATION_ENTRY_FILE = exports.AUTOMATION_ENDPOINT_NAME = exports.SOURCE_ROOTS = void 0;
4
4
  exports.listSourceDirs = listSourceDirs;
5
+ exports.findWorkspaceRoot = findWorkspaceRoot;
5
6
  exports.findSourceDir = findSourceDir;
6
7
  exports.sourceRootOf = sourceRootOf;
7
8
  exports.endpointEntryFile = endpointEntryFile;
@@ -30,6 +31,22 @@ function listSourceDirs(cwd = ".") {
30
31
  }
31
32
  return dirs;
32
33
  }
34
+ /**
35
+ * The workspace root at or above `cwd`: the nearest directory holding a
36
+ * `zite.config.json` next to an `apps/` or `automations/` dir. An app dir has
37
+ * a config of its own but no source root under it, so it never matches.
38
+ * Undefined outside a monorepo (a single-app project runs where it is).
39
+ */
40
+ function findWorkspaceRoot(cwd = ".") {
41
+ for (let dir = (0, path_1.resolve)(cwd);; dir = (0, path_1.dirname)(dir)) {
42
+ if ((0, fs_1.existsSync)((0, path_1.join)(dir, "zite.config.json")) &&
43
+ exports.SOURCE_ROOTS.some((root) => (0, fs_1.existsSync)((0, path_1.join)(dir, root)))) {
44
+ return dir;
45
+ }
46
+ if ((0, path_1.dirname)(dir) === dir)
47
+ return undefined;
48
+ }
49
+ }
33
50
  /** The source dir with this bare name, under whichever root holds it. */
34
51
  function findSourceDir(name, cwd = ".") {
35
52
  return listSourceDirs(cwd).find((d) => d.dir === name);
@@ -27,4 +27,19 @@ const sourceRoots_js_1 = require("./sourceRoots.js");
27
27
  (0, vitest_1.expect)((0, sourceRoots_js_1.listEndpointNames)("automations", (0, path_1.join)(repo, "automations/digest"))).toEqual(["index"]);
28
28
  (0, vitest_1.expect)((0, sourceRoots_js_1.listEndpointNames)("automations", (0, path_1.join)(repo, "automations/empty"))).toEqual([]);
29
29
  });
30
+ (0, vitest_1.it)("finds the workspace root from inside an app dir, and not from an app's own config", () => {
31
+ const repo = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "zitejs-root-"));
32
+ (0, fs_1.writeFileSync)((0, path_1.join)(repo, "zite.config.json"), "{}");
33
+ (0, fs_1.mkdirSync)((0, path_1.join)(repo, "apps/crm/src/api"), { recursive: true });
34
+ (0, fs_1.writeFileSync)((0, path_1.join)(repo, "apps/crm/zite.config.json"), "{}");
35
+ (0, vitest_1.expect)((0, sourceRoots_js_1.findWorkspaceRoot)(repo)).toBe(repo);
36
+ (0, vitest_1.expect)((0, sourceRoots_js_1.findWorkspaceRoot)((0, path_1.join)(repo, "apps/crm"))).toBe(repo);
37
+ (0, vitest_1.expect)((0, sourceRoots_js_1.findWorkspaceRoot)((0, path_1.join)(repo, "apps/crm/src/api"))).toBe(repo);
38
+ });
39
+ (0, vitest_1.it)("has no root for a single-app project", () => {
40
+ const app = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "zitejs-single-"));
41
+ (0, fs_1.writeFileSync)((0, path_1.join)(app, "zite.config.json"), "{}");
42
+ (0, fs_1.mkdirSync)((0, path_1.join)(app, "src/api"), { recursive: true });
43
+ (0, vitest_1.expect)((0, sourceRoots_js_1.findWorkspaceRoot)(app)).toBeUndefined();
44
+ });
30
45
  });
@@ -1232,10 +1232,10 @@ function generateBackendWrapperTs(envVarNames = []) {
1232
1232
  "// Auto-generated type-narrowing wrapper. Do not edit manually.",
1233
1233
  "// Re-exports createEndpoint with context.user typed to the app User.",
1234
1234
  "",
1235
- "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1235
+ "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStep, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1236
1236
  "import type { User } from 'zitejs/auth';",
1237
1237
  "",
1238
- "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1238
+ "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStep, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1239
1239
  // A trigger's registration exports are typed against these, and they
1240
1240
  // only reach app code through this file.
1241
1241
  "export type { ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
@@ -1331,6 +1331,8 @@ function generateBackendWrapperTs(envVarNames = []) {
1331
1331
  " execute: (",
1332
1332
  " params: {",
1333
1333
  " input: TInput;",
1334
+ " /** Durable steps: step.run(name, fn) runs fn once per logical run — its result is saved before anything after it executes, and a resumed run replays completed steps from their saved results. Provided by the platform; never define a local stand-in. */",
1335
+ " step: ZiteStep;",
1334
1336
  " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1335
1337
  // Mirrors the base module: a trigger's run gets `trigger`.
1336
1338
  " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}) & (TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {}),",
@@ -216,6 +216,14 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
216
216
  validatesInput?: boolean;
217
217
  execute: (params: {
218
218
  input: TInput;
219
+ /**
220
+ * Durable steps. `step.run(name, fn)` runs `fn` once per logical run:
221
+ * its result is saved before anything after it executes, and when a
222
+ * failed run is resumed, completed steps return their saved results
223
+ * instead of re-running their side effects. Names must be unique within
224
+ * a run, and results must be plain JSON data under 256KB.
225
+ */
226
+ step: ZiteStep;
219
227
  context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;
220
228
  } & (TStream extends true ? {
221
229
  stream: ZiteStreamInterface;
@@ -223,6 +231,17 @@ export interface EndpointConfig<TInput = unknown, TOutput = unknown, TStream ext
223
231
  trigger?: ZiteTriggerContext<TTrigger>;
224
232
  } : {})) => Promise<TOutput> | TOutput;
225
233
  }
234
+ /**
235
+ * Durable steps, provided by the platform inside `execute`. Wrap each unit of
236
+ * work with side effects — a provider call, a message send, a batch write —
237
+ * so a retry of a failed run resumes at the failure instead of redoing
238
+ * completed work. Steps run at least once — a crash between the side effect
239
+ * and the save re-runs the step — so make a step idempotent where repeating
240
+ * it would matter, and give a step inside a loop the item's id in its name.
241
+ */
242
+ export interface ZiteStep {
243
+ run<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
244
+ }
226
245
  /**
227
246
  * Create an endpoint definition, parsing `input` against `inputSchema` before
228
247
  * `execute` runs. Nothing downstream parses it — the worker calls `execute` on
@@ -3,6 +3,10 @@ import { z } from "zod";
3
3
  import { createEndpoint, ZiteError, } from "./index.js";
4
4
  import { createTableClient } from "../runtime/index.js";
5
5
  const requestContext = { user: { id: "u1", email: "a@b.c", roles: [] } };
6
+ // Direct execute() calls need the step surface the runtime always provides.
7
+ const stepStub = {
8
+ run: async (_name, fn) => await fn(),
9
+ };
6
10
  afterEach(() => {
7
11
  vi.unstubAllGlobals();
8
12
  });
@@ -13,7 +17,11 @@ describe("createEndpoint input validation", () => {
13
17
  inputSchema: z.object({ token: z.string().min(10) }),
14
18
  execute,
15
19
  });
16
- await expect(endpoint.execute({ input: {}, context: requestContext })).rejects.toBeInstanceOf(ZiteError);
20
+ await expect(endpoint.execute({
21
+ input: {},
22
+ context: requestContext,
23
+ step: stepStub,
24
+ })).rejects.toBeInstanceOf(ZiteError);
17
25
  expect(execute).not.toHaveBeenCalled();
18
26
  });
19
27
  it("surfaces the failure as BAD_REQUEST and names the offending field", async () => {
@@ -21,7 +29,11 @@ describe("createEndpoint input validation", () => {
21
29
  inputSchema: z.object({ token: z.string().min(10) }),
22
30
  execute: async () => "unreachable",
23
31
  });
24
- await expect(endpoint.execute({ input: {}, context: requestContext })).rejects.toMatchObject({
32
+ await expect(endpoint.execute({
33
+ input: {},
34
+ context: requestContext,
35
+ step: stepStub,
36
+ })).rejects.toMatchObject({
25
37
  code: "BAD_REQUEST",
26
38
  message: expect.stringContaining("token"),
27
39
  });
@@ -33,6 +45,7 @@ describe("createEndpoint input validation", () => {
33
45
  execute,
34
46
  });
35
47
  await expect(endpoint.execute({
48
+ step: stepStub,
36
49
  input: undefined,
37
50
  context: requestContext,
38
51
  })).rejects.toMatchObject({ code: "BAD_REQUEST" });
@@ -47,6 +60,7 @@ describe("createEndpoint input validation", () => {
47
60
  execute: async ({ input }) => input,
48
61
  });
49
62
  await expect(endpoint.execute({
63
+ step: stepStub,
50
64
  input: { token: "abc" },
51
65
  context: requestContext,
52
66
  })).resolves.toEqual({ token: "abc", limit: 10 });
@@ -60,6 +74,7 @@ describe("createEndpoint input validation", () => {
60
74
  execute: async ({ input }) => input.action,
61
75
  });
62
76
  await expect(endpoint.execute({
77
+ step: stepStub,
63
78
  input: { token: "b69779ae-eeea", action: "load" },
64
79
  context: requestContext,
65
80
  })).resolves.toBe("load");
@@ -78,6 +93,7 @@ describe("createEndpoint input validation", () => {
78
93
  execute: async ({ input }) => input,
79
94
  });
80
95
  await expect(endpoint.execute({
96
+ step: stepStub,
81
97
  input: { anything: true },
82
98
  context: requestContext,
83
99
  })).resolves.toEqual({ anything: true });
@@ -90,6 +106,7 @@ describe("createEndpoint input validation", () => {
90
106
  execute: async (params) => params.stream === stream,
91
107
  });
92
108
  await expect(endpoint.execute({
109
+ step: stepStub,
93
110
  input: { prompt: "hi" },
94
111
  context: requestContext,
95
112
  stream,
@@ -108,6 +125,7 @@ describe("platform-triggered runs", () => {
108
125
  execute: async ({ input }) => input,
109
126
  });
110
127
  await expect(endpoint.execute({
128
+ step: stepStub,
111
129
  input: { __cron: { scheduleId: "s1" } },
112
130
  context: { user: null },
113
131
  })).resolves.toEqual({ __cron: { scheduleId: "s1" } });
@@ -122,14 +140,22 @@ describe("platform-triggered runs", () => {
122
140
  },
123
141
  execute: async ({ input }) => input ?? "(no input)",
124
142
  });
125
- await expect(endpoint.execute({ input: undefined, context: { user: null } })).resolves.toBe("(no input)");
143
+ await expect(endpoint.execute({
144
+ input: undefined,
145
+ context: { user: null },
146
+ step: stepStub,
147
+ })).resolves.toBe("(no input)");
126
148
  });
127
149
  it("still validates an ordinary endpoint called with no input", async () => {
128
150
  const endpoint = createEndpoint({
129
151
  inputSchema: z.string(),
130
152
  execute: async ({ input }) => input,
131
153
  });
132
- await expect(endpoint.execute({ input: undefined, context: requestContext })).rejects.toMatchObject({ code: "BAD_REQUEST" });
154
+ await expect(endpoint.execute({
155
+ input: undefined,
156
+ context: requestContext,
157
+ step: stepStub,
158
+ })).rejects.toMatchObject({ code: "BAD_REQUEST" });
133
159
  });
134
160
  it("hands a poll a typed trigger and skips the input schema for it", async () => {
135
161
  const handled = [];
@@ -148,6 +174,7 @@ describe("platform-triggered runs", () => {
148
174
  });
149
175
  const seen = new Set(["a"]);
150
176
  await expect(endpoint.execute({
177
+ step: stepStub,
151
178
  input: {},
152
179
  context: { user: null },
153
180
  trigger: {
@@ -216,6 +243,7 @@ describe("platform-triggered runs", () => {
216
243
  }),
217
244
  });
218
245
  await expect(endpoint.execute({
246
+ step: stepStub,
219
247
  input: { kind: "sync" },
220
248
  context: { user: null },
221
249
  trigger: {
@@ -226,7 +254,11 @@ describe("platform-triggered runs", () => {
226
254
  headers: {},
227
255
  deliveryId: "chan:2",
228
256
  },
229
- })).resolves.toEqual({ body: { kind: "sync" }, cursor: "sync1", delivery: "chan:2" });
257
+ })).resolves.toEqual({
258
+ body: { kind: "sync" },
259
+ cursor: "sync1",
260
+ delivery: "chan:2",
261
+ });
230
262
  });
231
263
  it("refuses events on a trigger that also polls", () => {
232
264
  createEndpoint({
@@ -243,6 +275,7 @@ describe("platform-triggered runs", () => {
243
275
  execute: async ({ input }) => input,
244
276
  });
245
277
  await expect(endpoint.execute({
278
+ step: stepStub,
246
279
  input: { __webhook: { headers: {} }, id: 7 },
247
280
  context: { user: null },
248
281
  })).resolves.toEqual({ __webhook: { headers: {} }, id: 7 });
@@ -281,6 +314,7 @@ describe("regression: token lookup on an endpoint called without a token", () =>
281
314
  await expect(buildEndpoint((nom) => matched.push(nom)).execute({
282
315
  input: {},
283
316
  context: requestContext,
317
+ step: stepStub,
284
318
  })).rejects.toMatchObject({ code: "BAD_REQUEST" });
285
319
  expect(dispatched).toEqual([]);
286
320
  expect(matched).toEqual([]);
@@ -1,7 +1,7 @@
1
1
  import { watch } from "fs";
2
2
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, } from "fs";
3
3
  import { dirname, join, resolve } from "path";
4
- import { listSourceDirs } from "../sourceRoots.js";
4
+ import { findWorkspaceRoot, listSourceDirs, } from "../sourceRoots.js";
5
5
  import { runSync } from "../sync/index.js";
6
6
  import { generateDbTs, generateApiTs, generateBackendWrapperTs, generateAirtableTs, findEmailIntegrationId, generateEmailSdk, generateIntegrationsIndex, } from "../sync/lib.js";
7
7
  import { gettableIntegrations, } from "../integrations/rules.js";
@@ -173,6 +173,13 @@ export async function runGenerate() {
173
173
  // The backend writes the schema/lock files to the sandbox before
174
174
  // calling this. Use `zitejs sync` separately for local dev (fetches
175
175
  // metadata from the backend API).
176
+ // Everything below reads relative paths, so run from an app dir this would
177
+ // find no source roots, write nothing, and still print "Done!".
178
+ const workspaceRoot = findWorkspaceRoot();
179
+ if (workspaceRoot && workspaceRoot !== resolve(".")) {
180
+ console.log(`Running from the workspace root: ${workspaceRoot}`);
181
+ process.chdir(workspaceRoot);
182
+ }
176
183
  // 1. Generate root .zite/db.ts from zite.schema.json
177
184
  try {
178
185
  if (existsSync("zite.schema.json")) {
@@ -15,6 +15,13 @@ export interface SourceDir {
15
15
  }
16
16
  /** Every source dir under every root, in root order then name order. */
17
17
  export declare function listSourceDirs(cwd?: string): SourceDir[];
18
+ /**
19
+ * The workspace root at or above `cwd`: the nearest directory holding a
20
+ * `zite.config.json` next to an `apps/` or `automations/` dir. An app dir has
21
+ * a config of its own but no source root under it, so it never matches.
22
+ * Undefined outside a monorepo (a single-app project runs where it is).
23
+ */
24
+ export declare function findWorkspaceRoot(cwd?: string): string | undefined;
18
25
  /** The source dir with this bare name, under whichever root holds it. */
19
26
  export declare function findSourceDir(name: string, cwd?: string): SourceDir | undefined;
20
27
  /**
@@ -22,6 +22,22 @@ export function listSourceDirs(cwd = ".") {
22
22
  }
23
23
  return dirs;
24
24
  }
25
+ /**
26
+ * The workspace root at or above `cwd`: the nearest directory holding a
27
+ * `zite.config.json` next to an `apps/` or `automations/` dir. An app dir has
28
+ * a config of its own but no source root under it, so it never matches.
29
+ * Undefined outside a monorepo (a single-app project runs where it is).
30
+ */
31
+ export function findWorkspaceRoot(cwd = ".") {
32
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
33
+ if (existsSync(join(dir, "zite.config.json")) &&
34
+ SOURCE_ROOTS.some((root) => existsSync(join(dir, root)))) {
35
+ return dir;
36
+ }
37
+ if (dirname(dir) === dir)
38
+ return undefined;
39
+ }
40
+ }
25
41
  /** The source dir with this bare name, under whichever root holds it. */
26
42
  export function findSourceDir(name, cwd = ".") {
27
43
  return listSourceDirs(cwd).find((d) => d.dir === name);
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
3
  import { join } from "path";
4
4
  import { describe, expect, it } from "vitest";
5
- import { endpointEntryFile, listEndpointNames, sourceRootOf, } from "./sourceRoots.js";
5
+ import { endpointEntryFile, findWorkspaceRoot, listEndpointNames, sourceRootOf, } from "./sourceRoots.js";
6
6
  describe("source roots", () => {
7
7
  it("reads the root from the source dir's parent", () => {
8
8
  expect(sourceRootOf("/repo/apps/crm")).toBe("apps");
@@ -25,4 +25,19 @@ describe("source roots", () => {
25
25
  expect(listEndpointNames("automations", join(repo, "automations/digest"))).toEqual(["index"]);
26
26
  expect(listEndpointNames("automations", join(repo, "automations/empty"))).toEqual([]);
27
27
  });
28
+ it("finds the workspace root from inside an app dir, and not from an app's own config", () => {
29
+ const repo = mkdtempSync(join(tmpdir(), "zitejs-root-"));
30
+ writeFileSync(join(repo, "zite.config.json"), "{}");
31
+ mkdirSync(join(repo, "apps/crm/src/api"), { recursive: true });
32
+ writeFileSync(join(repo, "apps/crm/zite.config.json"), "{}");
33
+ expect(findWorkspaceRoot(repo)).toBe(repo);
34
+ expect(findWorkspaceRoot(join(repo, "apps/crm"))).toBe(repo);
35
+ expect(findWorkspaceRoot(join(repo, "apps/crm/src/api"))).toBe(repo);
36
+ });
37
+ it("has no root for a single-app project", () => {
38
+ const app = mkdtempSync(join(tmpdir(), "zitejs-single-"));
39
+ writeFileSync(join(app, "zite.config.json"), "{}");
40
+ mkdirSync(join(app, "src/api"), { recursive: true });
41
+ expect(findWorkspaceRoot(app)).toBeUndefined();
42
+ });
28
43
  });
@@ -1217,10 +1217,10 @@ export function generateBackendWrapperTs(envVarNames = []) {
1217
1217
  "// Auto-generated type-narrowing wrapper. Do not edit manually.",
1218
1218
  "// Re-exports createEndpoint with context.user typed to the app User.",
1219
1219
  "",
1220
- "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1220
+ "import type { ZiteRequestContext as _ZiteRequestContext, ZiteScheduledContext, ZiteErrorCode, ZiteSchedule, ZiteStep, ZiteStreamInterface, ZiteTrigger, ZiteTriggerContext, ZiteWebhook } from 'zitejs/backend/base';",
1221
1221
  "import type { User } from 'zitejs/auth';",
1222
1222
  "",
1223
- "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1223
+ "export type { ZiteErrorCode, ZiteSchedule, ZiteScheduledContext, ZiteStep, ZiteStreamInterface, ZiteTrigger, ZiteWebhook };",
1224
1224
  // A trigger's registration exports are typed against these, and they
1225
1225
  // only reach app code through this file.
1226
1226
  "export type { ZiteTriggerContext, ZiteTriggerDeliveryInfo, ZiteTriggerDeliveryRun, ZiteTriggerRun, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput } from 'zitejs/backend/base';",
@@ -1316,6 +1316,8 @@ export function generateBackendWrapperTs(envVarNames = []) {
1316
1316
  " execute: (",
1317
1317
  " params: {",
1318
1318
  " input: TInput;",
1319
+ " /** Durable steps: step.run(name, fn) runs fn once per logical run — its result is saved before anything after it executes, and a resumed run replays completed steps from their saved results. Provided by the platform; never define a local stand-in. */",
1320
+ " step: ZiteStep;",
1319
1321
  " context: TSchedule extends ZiteSchedule ? ZiteRequestContext | ZiteScheduledContext : TWebhook extends ZiteWebhook ? ZiteRequestContext | ZiteScheduledContext : TTrigger extends ZiteTrigger ? ZiteRequestContext | ZiteScheduledContext : ZiteRequestContext;",
1320
1322
  // Mirrors the base module: a trigger's run gets `trigger`.
1321
1323
  " } & (TStream extends true ? { stream: ZiteStreamInterface } : {}) & (TTrigger extends ZiteTrigger ? { trigger?: ZiteTriggerContext<TTrigger> } : {}),",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.136",
3
+ "version": "0.9.138",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "sideEffects": false,