@dudousxd/nestjs-durable-store-drizzle 0.20.0 → 0.22.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/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { StateStore, WorkflowRun, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
1
+ import { StateStore, WorkflowRun, RunStatus, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, CheckpointStat, CheckpointQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
2
2
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
3
3
  import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
4
4
 
@@ -14,6 +14,7 @@ declare class DrizzleStateStore implements StateStore {
14
14
  tryAdmitSingleton(runId: string, tag: string, workflow: string, limit: number, retryWakeAt?: number): Promise<boolean>;
15
15
  createRun(run: WorkflowRun): Promise<void>;
16
16
  updateRun(runId: string, inputPatch: Partial<WorkflowRun>): Promise<void>;
17
+ updateRunIf(runId: string, expectedStatuses: RunStatus[], inputPatch: Partial<WorkflowRun>): Promise<boolean>;
17
18
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
18
19
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
19
20
  private reindexAttributes;
@@ -24,6 +25,7 @@ declare class DrizzleStateStore implements StateStore {
24
25
  getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
25
26
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
26
27
  saveCheckpointIfPending(checkpoint: StepCheckpoint): Promise<boolean>;
28
+ saveCheckpointsIfPending(checkpoints: StepCheckpoint[]): Promise<boolean[]>;
27
29
  transaction<T>(work: (tx: {
28
30
  raw: unknown;
29
31
  saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
@@ -31,6 +33,7 @@ declare class DrizzleStateStore implements StateStore {
31
33
  /** Recovery on boot. Namespace-scoped: a worker re-drives only its own tenant's crashed runs.
32
34
  * `and(...)` drops an `undefined` predicate, so an unscoped caller recovers every namespace. */
33
35
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
36
+ listOrphanedRuns(nowMs: number, limit: number, namespace?: string): Promise<WorkflowRun[]>;
34
37
  /** Dispatch pick-up. Namespace-scoped — without it a worker serving one tenant drains another
35
38
  * tenant's queue, and the `limit` makes it worse: foreign runs consume the FIFO budget. */
36
39
  listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
@@ -67,6 +70,14 @@ declare class DrizzleStateStore implements StateStore {
67
70
  getLatestCheckpointByName(runId: string, name: string): Promise<StepCheckpoint | undefined>;
68
71
  countCheckpoints(runId: string, fromSeq: number, toSeq: number, statuses: StepCheckpoint['status'][]): Promise<number>;
69
72
  listCheckpointsByNamePrefix(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
73
+ /** {@link StateStore.checkpointStats}: one `GROUP BY parallel_group, status, name_kind` over the
74
+ * run's checkpoints, so a wide fan comes back as a few bucket rows instead of every member. */
75
+ checkpointStats(runId: string, opts?: {
76
+ group?: string | undefined;
77
+ }): Promise<CheckpointStat[]>;
78
+ /** {@link StateStore.queryCheckpoints}: the run's checkpoints narrowed in SQL, in `seq` order and
79
+ * paged there, so reading one page of a wide fan never loads the rest of it. */
80
+ queryCheckpoints(runId: string, query: CheckpointQuery): Promise<StepCheckpoint[]>;
70
81
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
71
82
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
72
83
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { StateStore, WorkflowRun, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
1
+ import { StateStore, WorkflowRun, RunStatus, RetentionPolicy, StepCheckpoint, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, CheckpointStat, CheckpointQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
2
2
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
3
3
  import { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core';
4
4
 
@@ -14,6 +14,7 @@ declare class DrizzleStateStore implements StateStore {
14
14
  tryAdmitSingleton(runId: string, tag: string, workflow: string, limit: number, retryWakeAt?: number): Promise<boolean>;
15
15
  createRun(run: WorkflowRun): Promise<void>;
16
16
  updateRun(runId: string, inputPatch: Partial<WorkflowRun>): Promise<void>;
17
+ updateRunIf(runId: string, expectedStatuses: RunStatus[], inputPatch: Partial<WorkflowRun>): Promise<boolean>;
17
18
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
18
19
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
19
20
  private reindexAttributes;
@@ -24,6 +25,7 @@ declare class DrizzleStateStore implements StateStore {
24
25
  getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
25
26
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
26
27
  saveCheckpointIfPending(checkpoint: StepCheckpoint): Promise<boolean>;
28
+ saveCheckpointsIfPending(checkpoints: StepCheckpoint[]): Promise<boolean[]>;
27
29
  transaction<T>(work: (tx: {
28
30
  raw: unknown;
29
31
  saveCheckpoint: (cp: StepCheckpoint) => Promise<void>;
@@ -31,6 +33,7 @@ declare class DrizzleStateStore implements StateStore {
31
33
  /** Recovery on boot. Namespace-scoped: a worker re-drives only its own tenant's crashed runs.
32
34
  * `and(...)` drops an `undefined` predicate, so an unscoped caller recovers every namespace. */
33
35
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
36
+ listOrphanedRuns(nowMs: number, limit: number, namespace?: string): Promise<WorkflowRun[]>;
34
37
  /** Dispatch pick-up. Namespace-scoped — without it a worker serving one tenant drains another
35
38
  * tenant's queue, and the `limit` makes it worse: foreign runs consume the FIFO budget. */
36
39
  listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
@@ -67,6 +70,14 @@ declare class DrizzleStateStore implements StateStore {
67
70
  getLatestCheckpointByName(runId: string, name: string): Promise<StepCheckpoint | undefined>;
68
71
  countCheckpoints(runId: string, fromSeq: number, toSeq: number, statuses: StepCheckpoint['status'][]): Promise<number>;
69
72
  listCheckpointsByNamePrefix(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
73
+ /** {@link StateStore.checkpointStats}: one `GROUP BY parallel_group, status, name_kind` over the
74
+ * run's checkpoints, so a wide fan comes back as a few bucket rows instead of every member. */
75
+ checkpointStats(runId: string, opts?: {
76
+ group?: string | undefined;
77
+ }): Promise<CheckpointStat[]>;
78
+ /** {@link StateStore.queryCheckpoints}: the run's checkpoints narrowed in SQL, in `seq` order and
79
+ * paged there, so reading one page of a wide fan never loads the rest of it. */
80
+ queryCheckpoints(runId: string, query: CheckpointQuery): Promise<StepCheckpoint[]>;
70
81
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
71
82
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
72
83
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable:
3
3
 
4
4
  // src/drizzle-state-store.ts
5
5
  import { RUN_VALUE_FACET_SCAN, SINGLETON_ACTIVE_STATUSES, TERMINAL_RUN_STATUSES, attributePredicateOperands, axisIsRunColumn, clearTerminalSingletonAdmission, mergeRunFacetRows, mergeRunValueFacetRows, normalizeAttributeRows, parseDuration, runValueFacetsFromRuns, singletonAdmissionDenialPatch, singletonAdmissionTags } from "@dudousxd/nestjs-durable-core";
6
- import { and, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql } from "drizzle-orm";
6
+ import { and, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, like, lt, lte, notInArray, or, sql } from "drizzle-orm";
7
7
 
8
8
  // src/schema.ts
9
9
  import { getTableName } from "drizzle-orm";
@@ -119,7 +119,13 @@ var stepCheckpoints = sqliteTable("durable_step_checkpoints", {
119
119
  t.runId,
120
120
  t.seq
121
121
  ]
122
- })
122
+ }),
123
+ // A run detail reads one fan at a time (`checkpointStats({ group })`, `queryCheckpoints({ group })`
124
+ // paged by seq). Like every index here, an already-deployed database needs it run by hand:
125
+ //
126
+ // CREATE INDEX IF NOT EXISTS durable_step_checkpoints_group_idx
127
+ // ON durable_step_checkpoints (run_id, parallel_group, seq);
128
+ index("durable_step_checkpoints_group_idx").on(t.runId, t.parallelGroup, t.seq)
123
129
  ]);
124
130
  var runAttributes = sqliteTable("durable_run_attributes", {
125
131
  runId: text("run_id").notNull(),
@@ -266,6 +272,21 @@ var DrizzleStateStore = class {
266
272
  if (Object.keys(row).length) await this.db.update(workflowRuns).set(row).where(eq(workflowRuns.id, runId));
267
273
  if ("searchAttributes" in patch) await this.reindexAttributes(runId, patch.searchAttributes);
268
274
  }
275
+ async updateRunIf(runId, expectedStatuses, inputPatch) {
276
+ if (expectedStatuses.length === 0) return false;
277
+ const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? await clearTerminalSingletonAdmission(runId, inputPatch, (id) => this.getRun(id)) : inputPatch;
278
+ const row = toRunPatch(patch);
279
+ if (!Object.keys(row).length) {
280
+ const current = await this.getRun(runId);
281
+ if (!current || !expectedStatuses.includes(current.status)) return false;
282
+ } else {
283
+ const result = await this.db.update(workflowRuns).set(row).where(and(eq(workflowRuns.id, runId), inArray(workflowRuns.status, expectedStatuses)));
284
+ const { changes, rowsAffected } = result;
285
+ if ((changes ?? rowsAffected ?? 0) === 0) return false;
286
+ }
287
+ if ("searchAttributes" in patch) await this.reindexAttributes(runId, patch.searchAttributes);
288
+ return true;
289
+ }
269
290
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
270
291
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
271
292
  async reindexAttributes(runId, attributes) {
@@ -341,6 +362,35 @@ var DrizzleStateStore = class {
341
362
  const { changes, rowsAffected } = result;
342
363
  return (changes ?? rowsAffected ?? 0) > 0;
343
364
  }
365
+ async saveCheckpointsIfPending(checkpoints) {
366
+ if (checkpoints.length === 0) return [];
367
+ const wrote = [];
368
+ await this.db.transaction((tx) => {
369
+ const update = /* @__PURE__ */ __name((checkpoint) => {
370
+ const { runId, seq, ...set } = toCheckpointRow(checkpoint);
371
+ return tx.update(stepCheckpoints).set(set).where(and(eq(stepCheckpoints.runId, runId), eq(stepCheckpoints.seq, seq), eq(stepCheckpoints.status, "pending"))).run();
372
+ }, "update");
373
+ const record = /* @__PURE__ */ __name((result) => {
374
+ const { changes, rowsAffected } = result;
375
+ wrote.push((changes ?? rowsAffected ?? 0) > 0);
376
+ }, "record");
377
+ const runFrom = /* @__PURE__ */ __name((from) => {
378
+ for (let i = from; i < checkpoints.length; i += 1) {
379
+ const result = update(checkpoints[i]);
380
+ if (result instanceof Promise) {
381
+ return result.then((r) => {
382
+ record(r);
383
+ return runFrom(i + 1);
384
+ });
385
+ }
386
+ record(result);
387
+ }
388
+ return void 0;
389
+ }, "runFrom");
390
+ return runFrom(0);
391
+ });
392
+ return wrote;
393
+ }
344
394
  async transaction(work) {
345
395
  return this.db.transaction(async (tx) => work({
346
396
  raw: tx,
@@ -365,6 +415,14 @@ var DrizzleStateStore = class {
365
415
  ]), namespaceFilter(namespace)));
366
416
  return rows.map(fromRunRow);
367
417
  }
418
+ async listOrphanedRuns(nowMs, limit, namespace) {
419
+ if (limit <= 0) return [];
420
+ const rows = await this.db.select().from(workflowRuns).where(and(inArray(workflowRuns.status, [
421
+ "running",
422
+ "cancelling"
423
+ ]), or(isNull(workflowRuns.lockedUntil), lte(workflowRuns.lockedUntil, nowMs)), namespaceFilter(namespace))).orderBy(asc(workflowRuns.createdAt)).limit(limit);
424
+ return rows.map(fromRunRow);
425
+ }
368
426
  /** Dispatch pick-up. Namespace-scoped — without it a worker serving one tenant drains another
369
427
  * tenant's queue, and the `limit` makes it worse: foreign runs consume the FIFO budget. */
370
428
  async listPendingRuns(limit, namespace) {
@@ -543,6 +601,61 @@ var DrizzleStateStore = class {
543
601
  async listCheckpointsByNamePrefix(runId, prefixes) {
544
602
  if (prefixes.length === 0) return [];
545
603
  const rows = await this.db.select().from(stepCheckpoints).where(and(eq(stepCheckpoints.runId, runId), or(...prefixes.map((p) => like(stepCheckpoints.name, `${p}%`))))).orderBy(asc(stepCheckpoints.seq));
604
+ return rows.filter((r) => prefixes.some((p) => r.name.startsWith(p))).map(fromCheckpointRow);
605
+ }
606
+ /** {@link StateStore.checkpointStats}: one `GROUP BY parallel_group, status, name_kind` over the
607
+ * run's checkpoints, so a wide fan comes back as a few bucket rows instead of every member. */
608
+ async checkpointStats(runId, opts) {
609
+ const c = stepCheckpoints;
610
+ const nameKind = sql`CASE
611
+ WHEN substr(${c.name}, 1, 13) = 'signal:child:' THEN 'child'
612
+ WHEN substr(${c.name}, 1, 4) = 'fan:' THEN 'folded'
613
+ WHEN substr(${c.name}, 1, 6) = 'spawn:' THEN 'spawn'
614
+ ELSE 'step' END`.as("name_kind");
615
+ const rows = await this.db.select({
616
+ group: c.parallelGroup,
617
+ status: c.status,
618
+ nameKind,
619
+ count: sql`count(*)`,
620
+ minSeq: sql`min(${c.seq})`,
621
+ maxSeq: sql`max(${c.seq})`,
622
+ minName: sql`min(${c.name})`,
623
+ maxName: sql`max(${c.name})`,
624
+ minEnqueuedAt: sql`min(coalesce(${c.enqueuedAt}, ${c.startedAt}))`,
625
+ minStartedAt: sql`min(${c.startedAt})`,
626
+ maxStartedAt: sql`max(${c.startedAt})`,
627
+ maxFinishedAt: sql`max(${c.finishedAt})`,
628
+ sumAttempts: sql`sum(${c.attempts})`
629
+ }).from(c).where(and(eq(c.runId, runId), opts?.group !== void 0 ? eq(c.parallelGroup, opts.group) : void 0)).groupBy(c.parallelGroup, c.status, sql`name_kind`);
630
+ return rows.map((r) => ({
631
+ group: r.group ?? null,
632
+ status: r.status,
633
+ nameKind: r.nameKind,
634
+ count: Number(r.count),
635
+ minSeq: Number(r.minSeq),
636
+ maxSeq: Number(r.maxSeq),
637
+ minName: r.minName,
638
+ maxName: r.maxName,
639
+ minEnqueuedAt: new Date(Number(r.minEnqueuedAt)),
640
+ minStartedAt: new Date(Number(r.minStartedAt)),
641
+ maxStartedAt: new Date(Number(r.maxStartedAt)),
642
+ maxFinishedAt: new Date(Number(r.maxFinishedAt)),
643
+ sumAttempts: Number(r.sumAttempts)
644
+ }));
645
+ }
646
+ /** {@link StateStore.queryCheckpoints}: the run's checkpoints narrowed in SQL, in `seq` order and
647
+ * paged there, so reading one page of a wide fan never loads the rest of it. */
648
+ async queryCheckpoints(runId, query) {
649
+ if (query.statuses?.length === 0 || query.namePrefixes?.length === 0) return [];
650
+ const c = stepCheckpoints;
651
+ const rows = await this.db.select().from(c).where(and(
652
+ eq(c.runId, runId),
653
+ query.group !== void 0 ? eq(c.parallelGroup, query.group) : void 0,
654
+ // `NOT IN` is never true for a NULL group, so ungrouped rows are kept explicitly.
655
+ query.excludeGroups?.length ? or(isNull(c.parallelGroup), notInArray(c.parallelGroup, query.excludeGroups)) : void 0,
656
+ query.statuses ? inArray(c.status, query.statuses) : void 0,
657
+ query.namePrefixes ? or(...query.namePrefixes.map((p) => sql`substr(${c.name}, 1, length(${p})) = ${p}`)) : void 0
658
+ )).orderBy(asc(c.seq)).limit(query.limit ?? Number.MAX_SAFE_INTEGER).offset(query.offset ?? 0);
546
659
  return rows.map(fromCheckpointRow);
547
660
  }
548
661
  async putSignalWaiter(waiter) {
@@ -574,7 +687,7 @@ var DrizzleStateStore = class {
574
687
  }
575
688
  async listSignalWaiters(prefix) {
576
689
  const rows = await this.db.select().from(signalWaiters).where(like(signalWaiters.token, `${prefix}%`));
577
- return rows.map((r) => ({
690
+ return rows.filter((r) => r.token.startsWith(prefix)).map((r) => ({
578
691
  token: r.token,
579
692
  runId: r.runId,
580
693
  seq: r.seq,