@gencow/core 0.1.22 → 0.1.24

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.
Files changed (44) hide show
  1. package/dist/crud.js +1 -1
  2. package/dist/index.d.ts +6 -1
  3. package/dist/index.js +3 -0
  4. package/dist/reactive.js +6 -0
  5. package/dist/rls-db.d.ts +43 -4
  6. package/dist/rls-db.js +212 -7
  7. package/dist/rls.d.ts +1 -1
  8. package/dist/rls.js +1 -1
  9. package/dist/scheduler.d.ts +35 -5
  10. package/dist/scheduler.js +83 -42
  11. package/dist/workflow-types.d.ts +81 -0
  12. package/dist/workflow-types.js +12 -0
  13. package/dist/workflow.d.ts +30 -0
  14. package/dist/workflow.js +157 -0
  15. package/dist/workflows-api.d.ts +13 -0
  16. package/dist/workflows-api.js +328 -0
  17. package/package.json +1 -1
  18. package/src/__tests__/crud-owner-rls.test.ts +6 -6
  19. package/src/__tests__/dist-exports.test.ts +6 -0
  20. package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
  21. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
  22. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
  23. package/src/__tests__/fixtures/basic/schema.ts +19 -3
  24. package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
  25. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
  26. package/src/__tests__/reactive.test.ts +161 -0
  27. package/src/__tests__/rls-crud-basic.test.ts +120 -161
  28. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
  29. package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
  30. package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
  31. package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
  32. package/src/__tests__/rls-session-and-policies.test.ts +246 -0
  33. package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
  34. package/src/__tests__/scheduler-durable.test.ts +173 -0
  35. package/src/__tests__/workflow.test.ts +583 -0
  36. package/src/crud.ts +1 -1
  37. package/src/index.ts +6 -4
  38. package/src/reactive.ts +8 -0
  39. package/src/rls-db.ts +277 -10
  40. package/src/rls.ts +1 -1
  41. package/src/scheduler.ts +124 -46
  42. package/src/workflow-types.ts +111 -0
  43. package/src/workflow.ts +205 -0
  44. package/src/workflows-api.ts +425 -0
@@ -0,0 +1,205 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { mutation } from "./reactive.js";
3
+ import type { MutationDef } from "./reactive.js";
4
+ import type {
5
+ WorkflowDef,
6
+ WorkflowDuration,
7
+ WorkflowOptions,
8
+ WorkflowStartResult,
9
+ } from "./workflow-types.js";
10
+ import { registerWorkflowsApi } from "./workflows-api.js";
11
+
12
+ declare global {
13
+ // eslint-disable-next-line no-var
14
+ var __gencow_workflowRegistry: Map<string, WorkflowDef<any, any>>;
15
+ }
16
+
17
+ const workflowRegistry =
18
+ globalThis.__gencow_workflowRegistry ??= new Map<string, WorkflowDef<any, any>>();
19
+
20
+ export const DEFAULT_WORKFLOW_MAX_DURATION_MS = 30 * 60 * 1000;
21
+ export const DEFAULT_WORKFLOW_MAX_RETRIES = 3;
22
+ export const WORKFLOW_RESUME_ACTION_PREFIX = "__gencow.workflow.resume";
23
+ export const WORKFLOW_REALTIME_KEY_PREFIX = "__gencow.workflow.state";
24
+
25
+ type SerializedWorkflowValue =
26
+ | { __gencowUndefined: true }
27
+ | { value: unknown };
28
+
29
+ function isSerializedWorkflowValue(value: unknown): value is SerializedWorkflowValue {
30
+ return !!value && typeof value === "object" && (
31
+ "__gencowUndefined" in value ||
32
+ "value" in value
33
+ );
34
+ }
35
+
36
+ export function serializeWorkflowValue(value: unknown): SerializedWorkflowValue {
37
+ const payload = value === undefined
38
+ ? { __gencowUndefined: true as const }
39
+ : { value };
40
+
41
+ try {
42
+ return JSON.parse(JSON.stringify(payload)) as SerializedWorkflowValue;
43
+ } catch (error) {
44
+ const reason = error instanceof Error ? error.message : String(error);
45
+ throw new Error(
46
+ `workflow() only persists JSON-serializable values. Failed to serialize workflow payload: ${reason}`
47
+ );
48
+ }
49
+ }
50
+
51
+ export function deserializeWorkflowValue(value: unknown): unknown {
52
+ if (!isSerializedWorkflowValue(value)) return value;
53
+ if ("__gencowUndefined" in value) return undefined;
54
+ return value.value;
55
+ }
56
+
57
+ function clampRetries(retries: number | undefined): number {
58
+ if (retries == null) return DEFAULT_WORKFLOW_MAX_RETRIES;
59
+ if (!Number.isFinite(retries) || retries < 0) {
60
+ throw new Error(`workflow() retries must be a non-negative finite number, got "${retries}"`);
61
+ }
62
+ return Math.floor(retries);
63
+ }
64
+
65
+ function parseDurationString(raw: string, label: string): number {
66
+ const normalized = raw.trim().toLowerCase();
67
+ const match = normalized.match(/^(\d+)(ms|s|m|h|d)$/);
68
+ if (!match) {
69
+ throw new Error(
70
+ `${label} must be a number of ms or a string like "30m", "90s", "1h" — got "${raw}"`
71
+ );
72
+ }
73
+ const value = Number(match[1]);
74
+ const unit = match[2];
75
+ const unitMs =
76
+ unit === "ms" ? 1 :
77
+ unit === "s" ? 1_000 :
78
+ unit === "m" ? 60_000 :
79
+ unit === "h" ? 3_600_000 :
80
+ 86_400_000;
81
+ return value * unitMs;
82
+ }
83
+
84
+ export function parseWorkflowDurationMs(
85
+ raw: WorkflowDuration,
86
+ label = "workflow duration"
87
+ ): number {
88
+ if (typeof raw === "number") {
89
+ if (!Number.isFinite(raw) || raw <= 0) {
90
+ throw new Error(`${label} must be a positive finite number, got "${raw}"`);
91
+ }
92
+ return Math.floor(raw);
93
+ }
94
+ if (typeof raw !== "string") {
95
+ throw new Error(
96
+ `${label} must be a positive finite number or a string like "30m", "90s", "1h" — got "${String(raw)}"`
97
+ );
98
+ }
99
+ return parseDurationString(raw, label);
100
+ }
101
+
102
+ function normalizeMaxDurationMs(maxDuration: WorkflowDuration | undefined): number {
103
+ if (maxDuration == null) return DEFAULT_WORKFLOW_MAX_DURATION_MS;
104
+ return parseWorkflowDurationMs(maxDuration, "workflow() maxDuration");
105
+ }
106
+
107
+ export function getWorkflowResumeActionName(name: string): string {
108
+ return `${WORKFLOW_RESUME_ACTION_PREFIX}.${name}`;
109
+ }
110
+
111
+ export function createWorkflowRealtimeToken(): string {
112
+ return crypto.randomUUID().replace(/-/g, "");
113
+ }
114
+
115
+ export function getWorkflowRealtimeKey(workflowId: string, realtimeToken: string): string {
116
+ return `${WORKFLOW_REALTIME_KEY_PREFIX}.${workflowId}.${realtimeToken}`;
117
+ }
118
+
119
+ export function getWorkflowDef(name: string): WorkflowDef | undefined {
120
+ return workflowRegistry.get(name);
121
+ }
122
+
123
+ export function getRegisteredWorkflows(): WorkflowDef[] {
124
+ return Array.from(workflowRegistry.values());
125
+ }
126
+
127
+ /**
128
+ * workflow() — durable multi-step execution with step memoization.
129
+ *
130
+ * The returned value is still a mutation definition, so existing API codegen and
131
+ * frontend hooks keep working without extra workflow-specific tooling.
132
+ */
133
+ export function workflow<TSchema = any, TReturn = any>(
134
+ name: string,
135
+ options: WorkflowOptions<TSchema, TReturn>
136
+ ): MutationDef<TSchema, WorkflowStartResult> {
137
+ registerWorkflowsApi();
138
+
139
+ const maxDurationMs = normalizeMaxDurationMs(options.maxDuration);
140
+ const maxRetries = clampRetries(options.retries);
141
+
142
+ const def: WorkflowDef<TSchema, TReturn> = {
143
+ name,
144
+ argsSchema: options.args,
145
+ isPublic: options.public === true,
146
+ maxDurationMs,
147
+ maxRetries,
148
+ handler: options.handler,
149
+ };
150
+
151
+ workflowRegistry.set(name, def);
152
+
153
+ return mutation<TSchema, WorkflowStartResult>(name, {
154
+ args: options.args,
155
+ public: options.public,
156
+ handler: async (ctx, args) => {
157
+ const workflowId = crypto.randomUUID();
158
+ const resumeAction = getWorkflowResumeActionName(name);
159
+ const ownerId = ctx.auth.getUserIdentity()?.id ?? null;
160
+ const persistedArgs = serializeWorkflowValue(args ?? {});
161
+ const realtimeToken = createWorkflowRealtimeToken();
162
+
163
+ await ctx.unsafeDb.execute(sql`
164
+ INSERT INTO _gencow_workflows (
165
+ id,
166
+ name,
167
+ args,
168
+ realtime_token,
169
+ status,
170
+ retry_count,
171
+ max_retries,
172
+ max_duration_ms,
173
+ user_id
174
+ )
175
+ VALUES (
176
+ ${workflowId},
177
+ ${name},
178
+ ${JSON.stringify(persistedArgs)}::jsonb,
179
+ ${realtimeToken},
180
+ 'pending',
181
+ 0,
182
+ ${maxRetries},
183
+ ${maxDurationMs},
184
+ ${ownerId}
185
+ )
186
+ `);
187
+
188
+ try {
189
+ const scheduledJobId = ctx.scheduler.runAfter(0, resumeAction, { workflowId });
190
+ return {
191
+ id: workflowId,
192
+ name,
193
+ status: "pending",
194
+ scheduledJobId,
195
+ };
196
+ } catch (error) {
197
+ await ctx.unsafeDb.execute(sql`
198
+ DELETE FROM _gencow_workflows
199
+ WHERE id = ${workflowId}
200
+ `);
201
+ throw error;
202
+ }
203
+ },
204
+ });
205
+ }
@@ -0,0 +1,425 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { mutation, query } from "./reactive.js";
3
+ import {
4
+ createWorkflowRealtimeToken,
5
+ deserializeWorkflowValue,
6
+ getWorkflowResumeActionName,
7
+ getWorkflowRealtimeKey,
8
+ serializeWorkflowValue,
9
+ } from "./workflow.js";
10
+ import { GencowValidationError, v } from "./v.js";
11
+ import type {
12
+ WorkflowDerivedStatus,
13
+ WorkflowSnapshot,
14
+ WorkflowSignalResult,
15
+ WorkflowStatus,
16
+ WorkflowStepSnapshot,
17
+ WorkflowSummary,
18
+ } from "./workflow-types.js";
19
+ import { deriveWorkflowStatus } from "./workflow-types.js";
20
+
21
+ declare global {
22
+ // eslint-disable-next-line no-var
23
+ var __gencow_workflowsApiRegistered: boolean | undefined;
24
+ }
25
+
26
+ type WorkflowRow = {
27
+ id: string;
28
+ name: string;
29
+ args: unknown;
30
+ status: WorkflowStatus;
31
+ current_step: string | null;
32
+ result: unknown;
33
+ error: string | null;
34
+ retry_count: number;
35
+ max_retries: number;
36
+ max_duration_ms: number;
37
+ started_at: string | Date;
38
+ updated_at: string | Date;
39
+ completed_at: string | Date | null;
40
+ realtime_token: string | null;
41
+ user_id: string | null;
42
+ };
43
+
44
+ type WorkflowStepRow = {
45
+ step_name: string;
46
+ status: WorkflowStatus;
47
+ output: unknown;
48
+ error: string | null;
49
+ started_at: string | Date | null;
50
+ updated_at: string | Date;
51
+ completed_at: string | Date | null;
52
+ };
53
+
54
+ type WorkflowSignalTargetRow = {
55
+ id: string;
56
+ name: string;
57
+ status: WorkflowStatus;
58
+ current_step: string | null;
59
+ user_id: string | null;
60
+ };
61
+
62
+ const WORKFLOW_STATUSES = new Set<WorkflowStatus>([
63
+ "pending",
64
+ "running",
65
+ "completed",
66
+ "failed",
67
+ ]);
68
+ const WORKFLOW_DERIVED_PENDING_STATUSES = new Set<WorkflowDerivedStatus>([
69
+ "queued",
70
+ "waiting",
71
+ "sleeping",
72
+ ]);
73
+
74
+ type WorkflowDbLike = {
75
+ execute: (query: unknown) => Promise<unknown>;
76
+ };
77
+
78
+ function rowsFromResult<T>(result: unknown): T[] {
79
+ if (Array.isArray(result)) return result as T[];
80
+ if (result && typeof result === "object" && Array.isArray((result as { rows?: unknown[] }).rows)) {
81
+ return (result as { rows: T[] }).rows;
82
+ }
83
+ return [];
84
+ }
85
+
86
+ function parseJsonField(value: unknown): unknown {
87
+ if (typeof value !== "string") return value;
88
+ try {
89
+ return JSON.parse(value);
90
+ } catch {
91
+ return value;
92
+ }
93
+ }
94
+
95
+ function toIsoString(value: string | Date): string {
96
+ if (value instanceof Date) return value.toISOString();
97
+ const parsed = new Date(value);
98
+ return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : String(value);
99
+ }
100
+
101
+ function toOptionalIsoString(value: string | Date | null): string | null {
102
+ return value ? toIsoString(value) : null;
103
+ }
104
+
105
+ function mapWorkflowSummary(row: WorkflowRow): WorkflowSummary {
106
+ return {
107
+ id: row.id,
108
+ name: row.name,
109
+ status: row.status,
110
+ derivedStatus: deriveWorkflowStatus(row.status, row.current_step),
111
+ currentStep: row.current_step,
112
+ error: row.error,
113
+ retryCount: row.retry_count,
114
+ maxRetries: row.max_retries,
115
+ maxDurationMs: Number(row.max_duration_ms),
116
+ startedAt: toIsoString(row.started_at),
117
+ updatedAt: toIsoString(row.updated_at),
118
+ completedAt: toOptionalIsoString(row.completed_at),
119
+ };
120
+ }
121
+
122
+ function mapWorkflowStep(row: WorkflowStepRow): WorkflowStepSnapshot {
123
+ return {
124
+ name: row.step_name,
125
+ status: row.status,
126
+ output: deserializeWorkflowValue(parseJsonField(row.output)),
127
+ error: row.error,
128
+ startedAt: toOptionalIsoString(row.started_at),
129
+ updatedAt: toIsoString(row.updated_at),
130
+ completedAt: toOptionalIsoString(row.completed_at),
131
+ };
132
+ }
133
+
134
+ function normalizeListLimit(limit: number | undefined): number {
135
+ if (limit == null) return 20;
136
+ if (!Number.isFinite(limit)) {
137
+ throw new GencowValidationError(`Argument "limit": expected a finite number, got ${limit}`);
138
+ }
139
+ return Math.max(1, Math.min(100, Math.floor(limit)));
140
+ }
141
+
142
+ function normalizeStatus(status: string | undefined): WorkflowStatus | undefined {
143
+ if (status == null) return undefined;
144
+ if (!WORKFLOW_STATUSES.has(status as WorkflowStatus)) {
145
+ throw new GencowValidationError(
146
+ `Argument "status": expected one of pending, running, completed, failed`
147
+ );
148
+ }
149
+ return status as WorkflowStatus;
150
+ }
151
+
152
+ function normalizeDerivedStatus(status: string | undefined): WorkflowDerivedStatus | undefined {
153
+ if (status == null) return undefined;
154
+ if (WORKFLOW_DERIVED_PENDING_STATUSES.has(status as WorkflowDerivedStatus)) {
155
+ return status as WorkflowDerivedStatus;
156
+ }
157
+ return normalizeStatus(status) as WorkflowDerivedStatus;
158
+ }
159
+
160
+ function toWorkflowStatusFilter(status: WorkflowDerivedStatus | undefined): WorkflowStatus | undefined {
161
+ if (status == null) return undefined;
162
+ if (WORKFLOW_DERIVED_PENDING_STATUSES.has(status)) {
163
+ return "pending";
164
+ }
165
+ return status as WorkflowStatus;
166
+ }
167
+
168
+ async function ensureWorkflowRealtimeToken(
169
+ db: WorkflowDbLike,
170
+ workflowId: string,
171
+ currentToken: string | null
172
+ ): Promise<string | null> {
173
+ if (currentToken && currentToken.trim() !== "") return currentToken;
174
+
175
+ const nextToken = createWorkflowRealtimeToken();
176
+ const updateResult = await db.execute(sql`
177
+ UPDATE _gencow_workflows
178
+ SET realtime_token = ${nextToken}
179
+ WHERE id = ${workflowId}
180
+ AND (realtime_token IS NULL OR realtime_token = '')
181
+ RETURNING realtime_token
182
+ `);
183
+ const updatedToken = rowsFromResult<{ realtime_token: string | null }>(updateResult)[0]?.realtime_token ?? null;
184
+ if (updatedToken && updatedToken.trim() !== "") return updatedToken;
185
+
186
+ const rereadResult = await db.execute(sql`
187
+ SELECT realtime_token
188
+ FROM _gencow_workflows
189
+ WHERE id = ${workflowId}
190
+ LIMIT 1
191
+ `);
192
+ const rereadToken = rowsFromResult<{ realtime_token: string | null }>(rereadResult)[0]?.realtime_token ?? null;
193
+ return rereadToken && rereadToken.trim() !== "" ? rereadToken : null;
194
+ }
195
+
196
+ async function loadWorkflowSignalTarget(
197
+ db: WorkflowDbLike,
198
+ workflowId: string
199
+ ): Promise<WorkflowSignalTargetRow | null> {
200
+ const result = await db.execute(sql`
201
+ SELECT
202
+ id,
203
+ name,
204
+ status,
205
+ current_step,
206
+ user_id
207
+ FROM _gencow_workflows
208
+ WHERE id = ${workflowId}
209
+ LIMIT 1
210
+ `);
211
+ return rowsFromResult<WorkflowSignalTargetRow>(result)[0] ?? null;
212
+ }
213
+
214
+ export async function loadWorkflowSnapshot(
215
+ db: WorkflowDbLike,
216
+ workflowId: string,
217
+ options?: {
218
+ viewerUserId?: string | null;
219
+ requireViewerMatch?: boolean;
220
+ }
221
+ ): Promise<WorkflowSnapshot | null> {
222
+ const workflowResult = await db.execute(sql`
223
+ SELECT
224
+ id,
225
+ name,
226
+ args,
227
+ status,
228
+ current_step,
229
+ result,
230
+ error,
231
+ retry_count,
232
+ max_retries,
233
+ max_duration_ms,
234
+ started_at,
235
+ updated_at,
236
+ completed_at,
237
+ realtime_token,
238
+ user_id
239
+ FROM _gencow_workflows
240
+ WHERE id = ${workflowId}
241
+ LIMIT 1
242
+ `);
243
+ const row = rowsFromResult<WorkflowRow>(workflowResult)[0] ?? null;
244
+ if (!row) return null;
245
+
246
+ const viewerUserId = options?.viewerUserId ?? null;
247
+ if (options?.requireViewerMatch && row.user_id && row.user_id !== viewerUserId) {
248
+ return null;
249
+ }
250
+
251
+ const realtimeToken = await ensureWorkflowRealtimeToken(db, workflowId, row.realtime_token);
252
+ if (!realtimeToken) return null;
253
+
254
+ const stepsResult = await db.execute(sql`
255
+ SELECT
256
+ step_name,
257
+ status,
258
+ output,
259
+ error,
260
+ started_at,
261
+ updated_at,
262
+ completed_at
263
+ FROM _gencow_workflow_steps
264
+ WHERE workflow_id = ${workflowId}
265
+ ORDER BY COALESCE(started_at, updated_at) ASC, step_name ASC
266
+ `);
267
+
268
+ return {
269
+ ...mapWorkflowSummary(row),
270
+ args: deserializeWorkflowValue(parseJsonField(row.args)),
271
+ result: deserializeWorkflowValue(parseJsonField(row.result)),
272
+ steps: rowsFromResult<WorkflowStepRow>(stepsResult).map(mapWorkflowStep),
273
+ realtimeKey: getWorkflowRealtimeKey(row.id, realtimeToken),
274
+ };
275
+ }
276
+
277
+ export function registerWorkflowsApi(): void {
278
+ if (globalThis.__gencow_workflowsApiRegistered) return;
279
+ globalThis.__gencow_workflowsApiRegistered = true;
280
+
281
+ query("workflows.get", {
282
+ args: { id: v.string() },
283
+ public: true,
284
+ handler: async (ctx, args): Promise<WorkflowSnapshot | null> => {
285
+ return loadWorkflowSnapshot(ctx.unsafeDb, args.id, {
286
+ viewerUserId: ctx.auth.getUserIdentity()?.id ?? null,
287
+ requireViewerMatch: true,
288
+ });
289
+ },
290
+ });
291
+
292
+ mutation("workflows.signal", {
293
+ args: {
294
+ id: v.string(),
295
+ event: v.string(),
296
+ payload: v.optional(v.any()),
297
+ },
298
+ public: true,
299
+ handler: async (ctx, args): Promise<WorkflowSignalResult> => {
300
+ const normalizedEvent = args.event.trim();
301
+ if (!normalizedEvent) {
302
+ throw new GencowValidationError(`Argument "event": expected a non-empty string`);
303
+ }
304
+
305
+ const workflow = await loadWorkflowSignalTarget(ctx.unsafeDb, args.id);
306
+ const viewerUserId = ctx.auth.getUserIdentity()?.id ?? null;
307
+
308
+ if (!workflow || (workflow.user_id && workflow.user_id !== viewerUserId)) {
309
+ return {
310
+ ok: false,
311
+ workflowId: args.id,
312
+ event: normalizedEvent,
313
+ scheduledJobId: null,
314
+ };
315
+ }
316
+
317
+ if (workflow.status === "completed" || workflow.status === "failed") {
318
+ return {
319
+ ok: false,
320
+ workflowId: workflow.id,
321
+ event: normalizedEvent,
322
+ scheduledJobId: null,
323
+ };
324
+ }
325
+
326
+ const persistedPayload = serializeWorkflowValue(args.payload);
327
+ await ctx.unsafeDb.execute(sql`
328
+ INSERT INTO _gencow_workflow_events (
329
+ id,
330
+ workflow_id,
331
+ event_name,
332
+ payload
333
+ )
334
+ VALUES (
335
+ ${crypto.randomUUID()},
336
+ ${workflow.id},
337
+ ${normalizedEvent},
338
+ ${JSON.stringify(persistedPayload)}::jsonb
339
+ )
340
+ `);
341
+
342
+ let scheduledJobId: string | null = null;
343
+ if (workflow.status === "pending" && workflow.current_step?.startsWith("wait:")) {
344
+ try {
345
+ scheduledJobId = ctx.scheduler.runAfter(
346
+ 0,
347
+ getWorkflowResumeActionName(workflow.name),
348
+ { workflowId: workflow.id }
349
+ );
350
+ } catch {
351
+ scheduledJobId = null;
352
+ }
353
+ }
354
+
355
+ return {
356
+ ok: true,
357
+ workflowId: workflow.id,
358
+ event: normalizedEvent,
359
+ scheduledJobId,
360
+ };
361
+ },
362
+ });
363
+
364
+ query("workflows.list", {
365
+ args: {
366
+ limit: v.optional(v.number()),
367
+ status: v.optional(v.string()),
368
+ },
369
+ handler: async (ctx, args): Promise<WorkflowSummary[]> => {
370
+ const userId = ctx.auth.requireAuth().id;
371
+ const limit = normalizeListLimit(args.limit);
372
+ const requestedStatus = normalizeDerivedStatus(args.status);
373
+ const status = toWorkflowStatusFilter(requestedStatus);
374
+
375
+ const result = status == null
376
+ ? await ctx.unsafeDb.execute(sql`
377
+ SELECT
378
+ id,
379
+ name,
380
+ args,
381
+ status,
382
+ current_step,
383
+ result,
384
+ error,
385
+ retry_count,
386
+ max_retries,
387
+ max_duration_ms,
388
+ started_at,
389
+ updated_at,
390
+ completed_at,
391
+ user_id
392
+ FROM _gencow_workflows
393
+ WHERE user_id = ${userId}
394
+ ORDER BY started_at DESC
395
+ LIMIT ${limit}
396
+ `)
397
+ : await ctx.unsafeDb.execute(sql`
398
+ SELECT
399
+ id,
400
+ name,
401
+ args,
402
+ status,
403
+ current_step,
404
+ result,
405
+ error,
406
+ retry_count,
407
+ max_retries,
408
+ max_duration_ms,
409
+ started_at,
410
+ updated_at,
411
+ completed_at,
412
+ user_id
413
+ FROM _gencow_workflows
414
+ WHERE user_id = ${userId}
415
+ AND status = ${status}
416
+ ORDER BY started_at DESC
417
+ LIMIT ${limit}
418
+ `);
419
+
420
+ return rowsFromResult<WorkflowRow>(result)
421
+ .map(mapWorkflowSummary)
422
+ .filter((row) => requestedStatus == null || row.derivedStatus === requestedStatus);
423
+ },
424
+ });
425
+ }