vela 0.12.2 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vela",
3
- "version": "0.12.2",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "A CLI for creating and updating SvelteKit projects",
6
6
  "license": "MIT",
@@ -36,7 +36,7 @@
36
36
  "dependencies": {
37
37
  "@clack/prompts": "^1.7.0",
38
38
  "@faker-js/faker": "^10.6.0",
39
- "@velastack/patterns": "^0.2.6",
39
+ "@velastack/patterns": "^0.2.8",
40
40
  "@velastack/pocketbase-codegen": "^0.1.0",
41
41
  "annotate-json-schema": "^0.1.0",
42
42
  "commander": "^13.1.0",
@@ -49,7 +49,7 @@
49
49
  "package-manager-detector": "^1.8.0",
50
50
  "picocolors": "^1.1.1",
51
51
  "pocketbase": "^0.28.0",
52
- "pocketbase-server": "^0.39.11",
52
+ "pocketbase-server": "^0.40.4",
53
53
  "stripe": "^19.3.0",
54
54
  "svelte": "^5.56.10",
55
55
  "tar": "^7.5.22",
@@ -27,8 +27,11 @@
27
27
  "@velastack/kit": "^0.3.0",
28
28
  "@velastack/pocketbase": "^0.2.2",
29
29
  "clsx": "^2.1.1",
30
+ "croner": "^10.0.1",
30
31
  "formsnap": "^2.0.1",
31
32
  "mode-watcher": "^1.1.0",
33
+ "openworkflow": "^0.10.0",
34
+ "openworkflow-pocketbase": "^0.1.1",
32
35
  "pocketbase-sveltekit": "^0.28.0",
33
36
  "prettier": "^3.9.6",
34
37
  "prettier-plugin-svelte": "^4.1.1",
@@ -1,8 +1,13 @@
1
+ import type { ServerInit } from '@sveltejs/kit';
1
2
  import { env } from '$env/dynamic/private';
2
3
  import { handlePocketbase } from '@velastack/pocketbase';
4
+ import { startWorker } from '$lib/server/workflows';
3
5
 
4
6
  export const handle = handlePocketbase({
5
7
  pocketbaseUrl: env.POCKETBASE_URL,
6
8
  superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
7
9
  superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD
8
10
  });
11
+
12
+ // Runs once when the server starts: executes the workflows in src/lib/workflows.
13
+ export const init: ServerInit = () => startWorker();
@@ -0,0 +1,172 @@
1
+ import process from 'node:process';
2
+ import { building } from '$app/environment';
3
+ import { env } from '$env/dynamic/private';
4
+ import { Cron } from 'croner';
5
+ import { OpenWorkflow, type Worker } from 'openworkflow';
6
+ import { BackendPocketBase } from 'openworkflow-pocketbase';
7
+ import PocketBase from 'pocketbase-sveltekit';
8
+
9
+ /**
10
+ * Background workflows on the OpenWorkflow engine built into PocketBase.
11
+ *
12
+ * Define one in src/lib/workflows/<name>.ts with `ow.defineWorkflow(...)` and
13
+ * start a run from any server code with `.run(input)`. The worker that `init`
14
+ * in src/hooks.server.ts starts claims runs from PocketBase and executes them
15
+ * in this process; every step's result is saved, so an interrupted run resumes
16
+ * from the last completed step. See src/lib/workflows/README.md.
17
+ */
18
+
19
+ const url = env.POCKETBASE_URL ?? '';
20
+ const email = env.POCKETBASE_SUPERUSER_EMAIL ?? '';
21
+ const password = env.POCKETBASE_SUPERUSER_PASSWORD ?? '';
22
+
23
+ /** The OpenWorkflow client: `defineWorkflow`, `runWorkflow`, `cancelWorkflowRun`, `sendSignal`. */
24
+ export const ow = new OpenWorkflow({
25
+ // Authenticates lazily and again after a 401, so constructing it costs nothing.
26
+ backend: new BackendPocketBase({ url, email, password })
27
+ });
28
+
29
+ const admin = new PocketBase(url);
30
+ admin.autoCancellation(false);
31
+
32
+ /** A superuser client for workflow steps. Signs in on first use and again once the token expires. */
33
+ export async function getAdmin(): Promise<App.Locals['admin']> {
34
+ if (!admin.authStore.isValid) {
35
+ await admin.collection('_superusers').authWithPassword(email, password);
36
+ }
37
+ return admin as App.Locals['admin'];
38
+ }
39
+
40
+ /**
41
+ * Every workflow module, loaded on demand rather than eagerly: an eager glob is
42
+ * hoisted above `ow`, and each module imports `ow` from this file.
43
+ */
44
+ const modules = import.meta.glob(['../workflows/*.ts', '!../workflows/*.test.ts']);
45
+
46
+ interface Runnable {
47
+ run(input?: undefined, options?: { idempotencyKey?: string }): Promise<unknown>;
48
+ workflow: { spec: { name: string } };
49
+ }
50
+
51
+ function isRunnable(value: unknown): value is Runnable {
52
+ const candidate = value as Partial<Runnable> | null;
53
+ return (
54
+ typeof candidate === 'object' &&
55
+ candidate !== null &&
56
+ typeof candidate.run === 'function' &&
57
+ typeof candidate.workflow?.spec?.name === 'string'
58
+ );
59
+ }
60
+
61
+ interface WorkflowModule {
62
+ file: string;
63
+ workflows: Runnable[];
64
+ /** A cron expression: every workflow in the module runs on that schedule. */
65
+ cron?: string;
66
+ }
67
+
68
+ async function loadModules(): Promise<WorkflowModule[]> {
69
+ return Promise.all(
70
+ Object.entries(modules).map(async ([file, load]) => {
71
+ const mod = (await load()) as Record<string, unknown>;
72
+ return {
73
+ file,
74
+ workflows: Object.values(mod).filter(isRunnable),
75
+ cron: typeof mod.cron === 'string' ? mod.cron : undefined
76
+ };
77
+ })
78
+ );
79
+ }
80
+
81
+ /**
82
+ * One run per workflow per minute, however many servers are running: the
83
+ * idempotency key makes PocketBase return the existing run to the others.
84
+ */
85
+ async function runRecurring(workflows: Runnable[], now: Date) {
86
+ const minute = new Date(Math.floor(now.getTime() / 60_000) * 60_000).toISOString();
87
+ for (const workflow of workflows) {
88
+ const name = workflow.workflow.spec.name;
89
+ try {
90
+ await workflow.run(undefined, { idempotencyKey: `cron:${name}:${minute}` });
91
+ } catch (error) {
92
+ console.error(`[workflows] could not start ${name}:`, error);
93
+ }
94
+ }
95
+ }
96
+
97
+ /** Starts every recurring workflow now, as if each schedule had just fired. For tests. */
98
+ export async function tickCron(now = new Date()) {
99
+ for (const mod of await loadModules()) {
100
+ if (mod.cron) await runRecurring(mod.workflows, now);
101
+ }
102
+ }
103
+
104
+ interface Running {
105
+ worker: Worker;
106
+ stop: () => Promise<void>;
107
+ }
108
+
109
+ // Keyed on globalThis: in development Vite re-evaluates this module when a
110
+ // workflow file changes, and the previous worker must stop polling.
111
+ const KEY = Symbol.for('velastack.workflows');
112
+ const state = globalThis as { [KEY]?: Running };
113
+
114
+ /**
115
+ * Starts the worker for this process. Returns without doing anything when
116
+ * there are no workflow modules, during `vela build`, or when
117
+ * WORKFLOWS_ENABLED=false, so a project that has no workflows never polls.
118
+ */
119
+ export async function startWorker() {
120
+ if (building || process.env.VITE_BUILD === 'true') return;
121
+ if (env.WORKFLOWS_ENABLED === 'false' || !url) return;
122
+ if (Object.keys(modules).length === 0) return;
123
+
124
+ const loaded = await loadModules();
125
+ const names = loaded.flatMap((mod) => mod.workflows.map((w) => w.workflow.spec.name));
126
+ if (names.length === 0) return;
127
+
128
+ // Not awaited: runs in flight finish on the old code, new claims stop now.
129
+ void state[KEY]?.stop();
130
+
131
+ const worker = ow.newWorker({ concurrency: Number(env.WORKFLOWS_CONCURRENCY ?? 5) });
132
+ const crons =
133
+ env.TEST === 'true'
134
+ ? []
135
+ : loaded
136
+ .filter((mod) => mod.cron)
137
+ .map(
138
+ (mod) =>
139
+ new Cron(mod.cron!, { name: mod.file, protect: true, unref: true }, (job: Cron) =>
140
+ runRecurring(mod.workflows, job.currentRun() ?? new Date())
141
+ )
142
+ );
143
+
144
+ let stopped = false;
145
+ const stop = async () => {
146
+ if (stopped) return;
147
+ stopped = true;
148
+ process.off('sveltekit:shutdown', onShutdown);
149
+ process.off('SIGINT', onShutdown);
150
+ process.off('SIGTERM', onShutdown);
151
+ for (const cron of crons) cron.stop();
152
+ await worker.stop();
153
+ console.log('[workflows] worker stopped');
154
+ };
155
+ const onShutdown = () => void stop();
156
+
157
+ // adapter-node emits sveltekit:shutdown only once HTTP has drained; the
158
+ // signals come first, so the worker drains alongside the requests.
159
+ process.once('sveltekit:shutdown', onShutdown);
160
+ process.once('SIGINT', onShutdown);
161
+ process.once('SIGTERM', onShutdown);
162
+
163
+ state[KEY] = { worker, stop };
164
+ await worker.start();
165
+ console.log(`[workflows] worker started: ${names.join(', ')}`);
166
+ }
167
+
168
+ /** Stops the worker started in this process, waiting for runs in flight. */
169
+ export async function stopWorker() {
170
+ await state[KEY]?.stop();
171
+ delete state[KEY];
172
+ }
@@ -0,0 +1,52 @@
1
+ # Workflows
2
+
3
+ Background work that survives restarts. Each workflow runs on the OpenWorkflow engine built into PocketBase: every step's result is saved, a failed run retries with backoff, and an interrupted run resumes from its last completed step. Runs are listed under **Workflows** in the PocketBase dashboard.
4
+
5
+ Add one:
6
+
7
+ ```sh
8
+ vela generate workflow send-welcome-email
9
+ ```
10
+
11
+ That writes `send-welcome-email.ts` here, along with a test:
12
+
13
+ ```ts
14
+ import { z } from 'zod';
15
+ import { ow } from '$lib/server/workflows';
16
+
17
+ export const sendWelcomeEmail = ow.defineWorkflow(
18
+ {
19
+ name: 'send-welcome-email',
20
+ schema: z.object({ userId: z.string() }),
21
+ retryPolicy: { maximumAttempts: 3 }
22
+ },
23
+ async ({ input, step }) => {
24
+ await step.run({ name: 'send' }, async () => {
25
+ // ...
26
+ });
27
+ }
28
+ );
29
+ ```
30
+
31
+ Start a run from any server code, such as a form action, an API route or another workflow:
32
+
33
+ ```ts
34
+ await sendWelcomeEmail.run({ userId: user.id }, { idempotencyKey: user.id });
35
+ ```
36
+
37
+ `run()` returns as soon as the run is queued. The handle it returns has `result()` to wait for the output and `cancel()`. Pass `availableAt` to delay a run and `idempotencyKey` to make repeated calls reuse one run for 24 hours.
38
+
39
+ Recurring work is a workflow whose file also exports a cron expression:
40
+
41
+ ```sh
42
+ vela generate workflow sync-prices --cron '*/5 * * * *'
43
+ ```
44
+
45
+ Every workflow in that file starts on the schedule, once per minute across all servers.
46
+
47
+ Good to know:
48
+
49
+ - Steps are the unit of retry, so make each one safe to repeat.
50
+ - A workflow gets one attempt unless `retryPolicy` says otherwise.
51
+ - `getAdmin()` from `$lib/server/workflows` is a superuser client for use inside steps.
52
+ - The worker runs inside the web server. `WORKFLOWS_CONCURRENCY` (default 5) caps parallel runs and `WORKFLOWS_ENABLED=false` turns the worker off for a process that should only queue runs.