@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.
@@ -1,6 +1,6 @@
1
1
  import * as drizzle_orm_pg_core from 'drizzle-orm/pg-core';
2
2
  import { PgDatabase } from 'drizzle-orm/pg-core';
3
- import { StateStore, WorkflowRun, RetentionPolicy, StepCheckpoint, StoreTransaction, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
3
+ import { StateStore, WorkflowRun, RunStatus, RetentionPolicy, StepCheckpoint, StoreTransaction, RunQuery, RunFacetQuery, RunFacetRow, RunValueAxis, RunValueFacetOptions, RunValueFacetRow, ScheduleRecord, ScheduleQuery, CheckpointStat, CheckpointQuery, SignalWaiter, ChildWait } from '@dudousxd/nestjs-durable-core';
4
4
  import { SQL } from 'drizzle-orm';
5
5
 
6
6
  /**
@@ -79,6 +79,7 @@ declare class DrizzlePgStateStore implements StateStore {
79
79
  tryAdmitSingleton(runId: string, tag: string, workflow: string, limit: number, retryWakeAt?: number): Promise<boolean>;
80
80
  createRun(run: WorkflowRun): Promise<void>;
81
81
  updateRun(runId: string, inputPatch: Partial<WorkflowRun>): Promise<void>;
82
+ updateRunIf(runId: string, expectedStatuses: RunStatus[], inputPatch: Partial<WorkflowRun>): Promise<boolean>;
82
83
  getRun(runId: string): Promise<WorkflowRun | null>;
83
84
  deleteRun(runId: string): Promise<void>;
84
85
  deleteRuns(runIds: string[]): Promise<void>;
@@ -86,9 +87,11 @@ declare class DrizzlePgStateStore implements StateStore {
86
87
  getCheckpoint(runId: string, seq: number): Promise<StepCheckpoint | null>;
87
88
  saveCheckpoint(checkpoint: StepCheckpoint): Promise<void>;
88
89
  saveCheckpointIfPending(checkpoint: StepCheckpoint): Promise<boolean>;
90
+ saveCheckpointsIfPending(checkpoints: StepCheckpoint[]): Promise<boolean[]>;
89
91
  transaction<T>(work: (tx: StoreTransaction) => Promise<T>): Promise<T>;
90
92
  /** Crash recovery. Namespace-scoped: a worker re-drives only its own tenant's runs. */
91
93
  listIncompleteRuns(namespace?: string): Promise<WorkflowRun[]>;
94
+ listOrphanedRuns(nowMs: number, limit: number, namespace?: string): Promise<WorkflowRun[]>;
92
95
  /** FIFO pick-up, served by `durable_workflow_runs_namespace_status_idx`. The engine then leases
93
96
  * each run with {@link tryLockRun}; the listing itself takes no locks (it runs outside a tx). */
94
97
  listPendingRuns(limit: number, namespace?: string): Promise<WorkflowRun[]>;
@@ -118,6 +121,14 @@ declare class DrizzlePgStateStore implements StateStore {
118
121
  getLatestCheckpointByName(runId: string, name: string): Promise<StepCheckpoint | undefined>;
119
122
  countCheckpoints(runId: string, fromSeq: number, toSeq: number, statuses: StepCheckpoint['status'][]): Promise<number>;
120
123
  listCheckpointsByNamePrefix(runId: string, prefixes: string[]): Promise<StepCheckpoint[]>;
124
+ /** {@link StateStore.checkpointStats}: one `GROUP BY parallel_group, status, name_kind` over the
125
+ * run's checkpoints, so a wide fan comes back as a few bucket rows instead of every member. */
126
+ checkpointStats(runId: string, opts?: {
127
+ group?: string | undefined;
128
+ }): Promise<CheckpointStat[]>;
129
+ /** {@link StateStore.queryCheckpoints}: the run's checkpoints narrowed in SQL, in `seq` order and
130
+ * paged there, so reading one page of a wide fan never loads the rest of it. */
131
+ queryCheckpoints(runId: string, query: CheckpointQuery): Promise<StepCheckpoint[]>;
121
132
  putSignalWaiter(waiter: SignalWaiter): Promise<void>;
122
133
  takeSignalWaiter(token: string): Promise<SignalWaiter | null>;
123
134
  listSignalWaiters(prefix: string): Promise<SignalWaiter[]>;
@@ -159,6 +170,8 @@ interface DrizzlePgSqlExecutor {
159
170
  /** Turn a `$1`-placeholder SQL string + params into a parameterized drizzle `SQL` object, so it runs
160
171
  * through whatever driver the drizzle db wraps (node-postgres, postgres-js, PGlite, Neon, …). */
161
172
  declare function toDrizzleSql(text: string, params?: unknown[]): SQL;
173
+ /** node-postgres / PGlite hand back `{ rows }`; postgres-js hands back the row array itself. */
174
+ declare function rowsOf<T>(result: unknown): T[];
162
175
  /**
163
176
  * Build a `DbTransport` executor from your drizzle Postgres db — the broker-less, DBOS-style
164
177
  * transport (remote steps as rows, claimed with `SELECT … FOR UPDATE SKIP LOCKED`) on the SAME
@@ -2384,4 +2397,4 @@ declare const durablePgSchema: {
2384
2397
  */
2385
2398
  declare function durablePgManagedTables(): string[];
2386
2399
 
2387
- export { DEFAULT_NAMESPACE, DURABLE_PG_DDL, type DrizzlePgDatabase, type DrizzlePgSqlExecutor, DrizzlePgStateStore, type DrizzlePgStateStoreOptions, drizzlePgExecutor, durableBufferedEvents, durableBufferedSignals, durablePgManagedTables, durablePgSchema, durableRunAttributes, durableSchedules, durableSignalWaiters, durableStepCheckpoints, durableWorkflowRuns, ensureDrizzlePgDurableSchema, toDrizzleSql };
2400
+ export { DEFAULT_NAMESPACE, DURABLE_PG_DDL, type DrizzlePgDatabase, type DrizzlePgSqlExecutor, DrizzlePgStateStore, type DrizzlePgStateStoreOptions, drizzlePgExecutor, durableBufferedEvents, durableBufferedSignals, durablePgManagedTables, durablePgSchema, durableRunAttributes, durableSchedules, durableSignalWaiters, durableStepCheckpoints, durableWorkflowRuns, ensureDrizzlePgDurableSchema, rowsOf, toDrizzleSql };
package/dist/pg/index.js CHANGED
@@ -86,6 +86,7 @@ var DURABLE_PG_DDL = [
86
86
  `CREATE INDEX IF NOT EXISTS "durable_workflow_runs_namespace_status_idx" ON "durable_workflow_runs" USING btree ("namespace","status","created_at")`,
87
87
  `CREATE INDEX IF NOT EXISTS "durable_runs_tags_gin_idx" ON "durable_workflow_runs" USING gin ("tags" jsonb_path_ops)`,
88
88
  `CREATE INDEX IF NOT EXISTS "durable_runs_status_updated_idx" ON "durable_workflow_runs" USING btree ("status","updated_at")`,
89
+ `CREATE INDEX IF NOT EXISTS "durable_step_checkpoints_group_idx" ON "durable_step_checkpoints" USING btree ("run_id","parallel_group","seq")`,
89
90
  `CREATE INDEX IF NOT EXISTS "durable_run_attributes_num_idx" ON "durable_run_attributes" USING btree ("key","num_value")`,
90
91
  `CREATE INDEX IF NOT EXISTS "durable_run_attributes_str_idx" ON "durable_run_attributes" USING btree ("key","str_value")`,
91
92
  `CREATE INDEX IF NOT EXISTS "durable_signal_waiters_run_id_idx" ON "durable_signal_waiters" USING btree ("run_id")`,
@@ -104,7 +105,39 @@ __name(ensureDrizzlePgDurableSchema, "ensureDrizzlePgDurableSchema");
104
105
 
105
106
  // src/pg/drizzle-pg-state-store.ts
106
107
  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";
107
- import { and, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql as sql2 } from "drizzle-orm";
108
+ import { and, asc, desc, eq, exists, getTableColumns, gte, inArray, isNotNull, isNull, like, lt, lte, notInArray, or, sql as sql3 } from "drizzle-orm";
109
+
110
+ // src/pg/executor.ts
111
+ import { sql as sql2 } from "drizzle-orm";
112
+ function toDrizzleSql(text2, params = []) {
113
+ const chunks = [];
114
+ let last = 0;
115
+ for (const match of text2.matchAll(/\$(\d+)/g)) {
116
+ const index2 = Number(match[1]) - 1;
117
+ if (index2 < 0 || index2 >= params.length) throw new Error(`placeholder ${match[0]} has no bound parameter`);
118
+ chunks.push(sql2.raw(text2.slice(last, match.index)), sql2`${params[index2]}`);
119
+ last = (match.index ?? 0) + match[0].length;
120
+ }
121
+ chunks.push(sql2.raw(text2.slice(last)));
122
+ return sql2.join(chunks);
123
+ }
124
+ __name(toDrizzleSql, "toDrizzleSql");
125
+ function rowsOf(result) {
126
+ if (Array.isArray(result)) return result;
127
+ return result?.rows ?? [];
128
+ }
129
+ __name(rowsOf, "rowsOf");
130
+ function drizzlePgExecutor(db) {
131
+ return {
132
+ dialect: "postgres",
133
+ escapeId: /* @__PURE__ */ __name((id) => `"${id.replace(/"/g, '""')}"`, "escapeId"),
134
+ query: /* @__PURE__ */ __name(async (text2, params) => rowsOf(await db.execute(toDrizzleSql(text2, params))), "query"),
135
+ transaction: /* @__PURE__ */ __name((fn) => db.transaction((tx) => fn({
136
+ query: /* @__PURE__ */ __name(async (text2, params) => rowsOf(await tx.execute(toDrizzleSql(text2, params))), "query")
137
+ })), "transaction")
138
+ };
139
+ }
140
+ __name(drizzlePgExecutor, "drizzlePgExecutor");
108
141
 
109
142
  // src/pg/schema.ts
110
143
  import { getTableName } from "drizzle-orm";
@@ -182,7 +215,9 @@ var durableStepCheckpoints = pgTable(
182
215
  t.runId,
183
216
  t.seq
184
217
  ]
185
- })
218
+ }),
219
+ // A run detail reads one fan at a time: checkpointStats / queryCheckpoints by group, seq-paged.
220
+ index("durable_step_checkpoints_group_idx").on(t.runId, t.parallelGroup, t.seq)
186
221
  ]
187
222
  );
188
223
  var durableRunAttributes = pgTable("durable_run_attributes", {
@@ -271,7 +306,7 @@ __name(durablePgManagedTables, "durablePgManagedTables");
271
306
  // src/pg/drizzle-pg-state-store.ts
272
307
  function anyOf(conditions) {
273
308
  const present = conditions.filter((c) => c !== void 0);
274
- return present.length ? or(...present) : sql2`1 = 0`;
309
+ return present.length ? or(...present) : sql3`1 = 0`;
275
310
  }
276
311
  __name(anyOf, "anyOf");
277
312
  function namespaceFilter(namespace) {
@@ -279,7 +314,7 @@ function namespaceFilter(namespace) {
279
314
  }
280
315
  __name(namespaceFilter, "namespaceFilter");
281
316
  function hasTag(tag) {
282
- return sql2`${durableWorkflowRuns.tags} @> ${JSON.stringify([
317
+ return sql3`${durableWorkflowRuns.tags} @> ${JSON.stringify([
283
318
  tag
284
319
  ])}::jsonb`;
285
320
  }
@@ -351,6 +386,22 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
351
386
  if (reindex) await reindexAttributes(tx, runId, patch.searchAttributes);
352
387
  });
353
388
  }
389
+ async updateRunIf(runId, expectedStatuses, inputPatch) {
390
+ if (expectedStatuses.length === 0) return false;
391
+ const patch = inputPatch.status && TERMINAL_RUN_STATUSES.includes(inputPatch.status) ? await clearTerminalSingletonAdmission(runId, inputPatch, (id) => this.withScope({}).getRun(id)) : inputPatch;
392
+ const row = toRunPatch(patch);
393
+ const reindex = "searchAttributes" in patch;
394
+ const matches = and(eq(durableWorkflowRuns.id, runId), inArray(durableWorkflowRuns.status, expectedStatuses));
395
+ return this.db.transaction(async (tx) => {
396
+ const applied = Object.keys(row).length ? (await tx.update(durableWorkflowRuns).set(row).where(matches).returning({
397
+ id: durableWorkflowRuns.id
398
+ })).length === 1 : (await tx.select({
399
+ id: durableWorkflowRuns.id
400
+ }).from(durableWorkflowRuns).where(matches).for("update")).length === 1;
401
+ if (applied && reindex) await reindexAttributes(tx, runId, patch.searchAttributes);
402
+ return applied;
403
+ });
404
+ }
354
405
  async getRun(runId) {
355
406
  const rows = await this.db.select().from(durableWorkflowRuns).where(and(eq(durableWorkflowRuns.id, runId), this.scope())).limit(1);
356
407
  return rows[0] ? fromRunRow(rows[0]) : null;
@@ -420,6 +471,30 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
420
471
  });
421
472
  return rows.length === 1;
422
473
  }
474
+ async saveCheckpointsIfPending(checkpoints) {
475
+ const wrote = checkpoints.map(() => false);
476
+ if (checkpoints.length === 0) return wrote;
477
+ await this.db.transaction(async (tx) => {
478
+ for (const round of distinctRounds(checkpoints)) {
479
+ for (let at = 0; at < round.length; at += CHECKPOINT_BATCH_ROWS) {
480
+ const chunk = round.slice(at, at + CHECKPOINT_BATCH_ROWS);
481
+ const updated = rowsOf(await tx.execute(updateIfPendingFrom(chunk.map((i) => checkpoints[i]))));
482
+ const byKey = new Map(chunk.map((i) => [
483
+ rowKey(checkpoints[i]),
484
+ i
485
+ ]));
486
+ for (const row of updated) {
487
+ const i = byKey.get(rowKey({
488
+ runId: row.run_id,
489
+ seq: Number(row.seq)
490
+ }));
491
+ if (i !== void 0) wrote[i] = true;
492
+ }
493
+ }
494
+ }
495
+ });
496
+ return wrote;
497
+ }
423
498
  async transaction(work) {
424
499
  return this.db.transaction(async (tx) => work({
425
500
  raw: tx,
@@ -434,6 +509,14 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
434
509
  ]), namespaceFilter(namespace), this.scope()));
435
510
  return rows.map(fromRunRow);
436
511
  }
512
+ async listOrphanedRuns(nowMs, limit, namespace) {
513
+ if (limit <= 0) return [];
514
+ const rows = await this.db.select().from(durableWorkflowRuns).where(and(inArray(durableWorkflowRuns.status, [
515
+ "running",
516
+ "cancelling"
517
+ ]), or(isNull(durableWorkflowRuns.lockedUntil), lte(durableWorkflowRuns.lockedUntil, new Date(nowMs))), namespaceFilter(namespace), this.scope())).orderBy(asc(durableWorkflowRuns.createdAt)).limit(limit);
518
+ return rows.map(fromRunRow);
519
+ }
437
520
  /** FIFO pick-up, served by `durable_workflow_runs_namespace_status_idx`. The engine then leases
438
521
  * each run with {@link tryLockRun}; the listing itself takes no locks (it runs outside a tx). */
439
522
  async listPendingRuns(limit, namespace) {
@@ -473,16 +556,16 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
473
556
  return [
474
557
  this.scope(),
475
558
  query.workflow ? eq(durableWorkflowRuns.workflow, query.workflow) : void 0,
476
- query.workflows ? query.workflows.length ? inArray(durableWorkflowRuns.workflow, query.workflows) : sql2`1 = 0` : void 0,
559
+ query.workflows ? query.workflows.length ? inArray(durableWorkflowRuns.workflow, query.workflows) : sql3`1 = 0` : void 0,
477
560
  // The tenant boundary. Also the engine's execution-timeout sweep path
478
561
  // (`listRuns({ workflow, status, namespace })`) — unscoped, it would cancel another tenant's runs.
479
562
  namespaceFilter(query.namespace),
480
- query.namespaces ? query.namespaces.length ? inArray(durableWorkflowRuns.namespace, query.namespaces) : sql2`1 = 0` : void 0,
563
+ query.namespaces ? query.namespaces.length ? inArray(durableWorkflowRuns.namespace, query.namespaces) : sql3`1 = 0` : void 0,
481
564
  // `null` selects the unattributed bucket; a value never matches a NULL origin.
482
565
  query.origin === null ? isNull(durableWorkflowRuns.origin) : query.origin !== void 0 ? eq(durableWorkflowRuns.origin, query.origin) : void 0,
483
566
  query.origins ? anyOf(query.origins.map((o) => o === null ? isNull(durableWorkflowRuns.origin) : eq(durableWorkflowRuns.origin, o))) : void 0,
484
567
  query.status ? eq(durableWorkflowRuns.status, query.status) : void 0,
485
- query.statuses ? query.statuses.length ? inArray(durableWorkflowRuns.status, query.statuses) : sql2`1 = 0` : void 0,
568
+ query.statuses ? query.statuses.length ? inArray(durableWorkflowRuns.status, query.statuses) : sql3`1 = 0` : void 0,
486
569
  query.tag ? hasTag(query.tag) : void 0,
487
570
  query.tags ? anyOf(query.tags.map(hasTag)) : void 0,
488
571
  // One EXISTS per attribute predicate against the normalized side table (ANDed).
@@ -500,7 +583,7 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
500
583
  async countRuns(query) {
501
584
  const filters = this.runFilters(query);
502
585
  const rows = await this.db.select({
503
- count: sql2`count(*)`.mapWith(Number)
586
+ count: sql3`count(*)`.mapWith(Number)
504
587
  }).from(durableWorkflowRuns).where(filters.length ? and(...filters) : void 0);
505
588
  return rows[0]?.count ?? 0;
506
589
  }
@@ -509,7 +592,7 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
509
592
  const rows = await this.db.select({
510
593
  status: durableWorkflowRuns.status,
511
594
  origin: durableWorkflowRuns.origin,
512
- count: sql2`count(*)`.mapWith(Number)
595
+ count: sql3`count(*)`.mapWith(Number)
513
596
  }).from(durableWorkflowRuns).where(filters.length ? and(...filters) : void 0).groupBy(durableWorkflowRuns.status, durableWorkflowRuns.origin);
514
597
  return mergeRunFacetRows(rows.map((r) => ({
515
598
  status: r.status,
@@ -531,7 +614,7 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
531
614
  const filters = this.runFilters(query);
532
615
  const rows = await this.db.select({
533
616
  value: column,
534
- count: sql2`count(*)`.mapWith(Number)
617
+ count: sql3`count(*)`.mapWith(Number)
535
618
  }).from(durableWorkflowRuns).where(filters.length ? and(...filters) : void 0).groupBy(column);
536
619
  return mergeRunValueFacetRows(rows.map((r) => ({
537
620
  value: r.value ?? null,
@@ -542,9 +625,9 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
542
625
  * excludes runs lacking the key (EXISTS requires the row). Numbers compare `num_value`, strings
543
626
  * and booleans `str_value`; an `in` set ORs its members inside the one EXISTS. */
544
627
  attributeExists(f) {
545
- const comparisons = attributePredicateOperands(f).map(({ column, comparator, operand }) => sql2`${column === "numValue" ? durableRunAttributes.numValue : durableRunAttributes.strValue} ${sql2.raw(comparator)} ${operand}`);
628
+ const comparisons = attributePredicateOperands(f).map(({ column, comparator, operand }) => sql3`${column === "numValue" ? durableRunAttributes.numValue : durableRunAttributes.strValue} ${sql3.raw(comparator)} ${operand}`);
546
629
  return exists(this.db.select({
547
- one: sql2`1`
630
+ one: sql3`1`
548
631
  }).from(durableRunAttributes).where(and(eq(durableRunAttributes.runId, durableWorkflowRuns.id), eq(durableRunAttributes.key, f.key), anyOf(comparisons))));
549
632
  }
550
633
  // ---- persisted schedules ----------------------------------------------------------------------
@@ -575,9 +658,9 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
575
658
  return rows.length > 0;
576
659
  }
577
660
  async listSchedules(query) {
578
- const base = this.db.select().from(durableSchedules).where(and(query.namespace !== void 0 ? eq(durableSchedules.namespace, query.namespace) : void 0, query.workflow !== void 0 ? eq(durableSchedules.workflow, query.workflow) : void 0, query.paused !== void 0 ? eq(durableSchedules.paused, query.paused) : void 0, query.tag !== void 0 ? sql2`${durableSchedules.tags} @> ${JSON.stringify([
661
+ const base = this.db.select().from(durableSchedules).where(and(query.namespace !== void 0 ? eq(durableSchedules.namespace, query.namespace) : void 0, query.workflow !== void 0 ? eq(durableSchedules.workflow, query.workflow) : void 0, query.paused !== void 0 ? eq(durableSchedules.paused, query.paused) : void 0, query.tag !== void 0 ? sql3`${durableSchedules.tags} @> ${JSON.stringify([
579
662
  query.tag
580
- ])}::jsonb` : void 0, query.dueBy !== void 0 ? and(eq(durableSchedules.paused, false), lte(durableSchedules.nextFireAt, new Date(query.dueBy))) : void 0)).orderBy(sql2`${durableSchedules.nextFireAt} ASC NULLS LAST`, asc(durableSchedules.id)).$dynamic();
663
+ ])}::jsonb` : void 0, query.dueBy !== void 0 ? and(eq(durableSchedules.paused, false), lte(durableSchedules.nextFireAt, new Date(query.dueBy))) : void 0)).orderBy(sql3`${durableSchedules.nextFireAt} ASC NULLS LAST`, asc(durableSchedules.id)).$dynamic();
581
664
  if (query.limit != null) base.limit(query.limit);
582
665
  if (query.offset != null) base.offset(query.offset);
583
666
  return (await base).map(fromScheduleRow);
@@ -593,7 +676,7 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
593
676
  async countCheckpoints(runId, fromSeq, toSeq, statuses) {
594
677
  if (statuses.length === 0) return 0;
595
678
  const rows = await this.db.select({
596
- count: sql2`count(*)`.mapWith(Number)
679
+ count: sql3`count(*)`.mapWith(Number)
597
680
  }).from(durableStepCheckpoints).where(and(eq(durableStepCheckpoints.runId, runId), gte(durableStepCheckpoints.seq, fromSeq), lte(durableStepCheckpoints.seq, toSeq), inArray(durableStepCheckpoints.status, statuses)));
598
681
  return rows[0]?.count ?? 0;
599
682
  }
@@ -602,6 +685,53 @@ var DrizzlePgStateStore = class _DrizzlePgStateStore {
602
685
  const rows = await this.db.select().from(durableStepCheckpoints).where(and(eq(durableStepCheckpoints.runId, runId), or(...prefixes.map((p) => like(durableStepCheckpoints.name, `${escapeLike(p)}%`))))).orderBy(asc(durableStepCheckpoints.seq));
603
686
  return rows.map(fromCheckpointRow);
604
687
  }
688
+ /** {@link StateStore.checkpointStats}: one `GROUP BY parallel_group, status, name_kind` over the
689
+ * run's checkpoints, so a wide fan comes back as a few bucket rows instead of every member. */
690
+ async checkpointStats(runId, opts) {
691
+ const c = durableStepCheckpoints;
692
+ const nameKind = sql3`CASE
693
+ WHEN ${c.name} LIKE 'signal:child:%' THEN 'child'
694
+ WHEN ${c.name} LIKE 'fan:%' THEN 'folded'
695
+ WHEN ${c.name} LIKE 'spawn:%' THEN 'spawn'
696
+ ELSE 'step' END`.as("name_kind");
697
+ const rows = await this.db.select({
698
+ group: c.parallelGroup,
699
+ status: c.status,
700
+ nameKind,
701
+ count: sql3`count(*)`.mapWith(Number),
702
+ minSeq: sql3`min(${c.seq})`.mapWith(Number),
703
+ maxSeq: sql3`max(${c.seq})`.mapWith(Number),
704
+ minName: sql3`min(${c.name})`,
705
+ maxName: sql3`max(${c.name})`,
706
+ minEnqueuedAt: sql3`min(coalesce(${c.enqueuedAt}, ${c.startedAt}))`.mapWith(c.startedAt),
707
+ minStartedAt: sql3`min(${c.startedAt})`.mapWith(c.startedAt),
708
+ maxStartedAt: sql3`max(${c.startedAt})`.mapWith(c.startedAt),
709
+ maxFinishedAt: sql3`max(${c.finishedAt})`.mapWith(c.finishedAt),
710
+ sumAttempts: sql3`sum(${c.attempts})`.mapWith(Number)
711
+ }).from(c).where(and(eq(c.runId, runId), opts?.group !== void 0 ? eq(c.parallelGroup, opts.group) : void 0)).groupBy(c.parallelGroup, c.status, sql3`name_kind`);
712
+ return rows.map((r) => ({
713
+ ...r,
714
+ group: r.group ?? null,
715
+ status: r.status
716
+ }));
717
+ }
718
+ /** {@link StateStore.queryCheckpoints}: the run's checkpoints narrowed in SQL, in `seq` order and
719
+ * paged there, so reading one page of a wide fan never loads the rest of it. */
720
+ async queryCheckpoints(runId, query) {
721
+ if (query.statuses?.length === 0 || query.namePrefixes?.length === 0) return [];
722
+ const c = durableStepCheckpoints;
723
+ const base = this.db.select().from(c).where(and(
724
+ eq(c.runId, runId),
725
+ query.group !== void 0 ? eq(c.parallelGroup, query.group) : void 0,
726
+ // `NOT IN` is never true for a NULL group, so ungrouped rows are kept explicitly.
727
+ query.excludeGroups?.length ? or(isNull(c.parallelGroup), notInArray(c.parallelGroup, query.excludeGroups)) : void 0,
728
+ query.statuses ? inArray(c.status, query.statuses) : void 0,
729
+ query.namePrefixes ? or(...query.namePrefixes.map((p) => like(c.name, `${escapeLike(p)}%`))) : void 0
730
+ )).orderBy(asc(c.seq)).$dynamic();
731
+ if (query.limit != null) base.limit(query.limit);
732
+ if (query.offset != null) base.offset(query.offset);
733
+ return (await base).map(fromCheckpointRow);
734
+ }
605
735
  async putSignalWaiter(waiter) {
606
736
  const set = {
607
737
  runId: waiter.runId,
@@ -717,6 +847,41 @@ function escapeLike(value) {
717
847
  return value.replace(/[\\%_]/g, (c) => `\\${c}`);
718
848
  }
719
849
  __name(escapeLike, "escapeLike");
850
+ var CHECKPOINT_BATCH_ROWS = 500;
851
+ var rowKey = /* @__PURE__ */ __name((cp) => `${cp.seq}:${cp.runId}`, "rowKey");
852
+ function distinctRounds(checkpoints) {
853
+ const rounds = [];
854
+ const seen = /* @__PURE__ */ new Map();
855
+ checkpoints.forEach((cp, i) => {
856
+ const key = rowKey(cp);
857
+ const round = seen.get(key) ?? 0;
858
+ seen.set(key, round + 1);
859
+ const indexes = rounds[round] ?? [];
860
+ rounds[round] = indexes;
861
+ indexes.push(i);
862
+ });
863
+ return rounds;
864
+ }
865
+ __name(distinctRounds, "distinctRounds");
866
+ function updateIfPendingFrom(checkpoints) {
867
+ const json = /* @__PURE__ */ __name((v) => v == null ? null : JSON.stringify(v), "json");
868
+ const ts = /* @__PURE__ */ __name((v) => v == null ? null : v.toISOString(), "ts");
869
+ const values = checkpoints.map((cp) => {
870
+ const r = toCheckpointRow(cp);
871
+ return sql3`(${r.runId}::text, ${r.seq}::integer, ${r.name}::text, ${r.kind}::text, ${r.stepId}::text, ${r.status}::text, ${json(r.input)}::jsonb, ${json(r.output)}::jsonb, ${json(r.error)}::jsonb, ${json(r.events)}::jsonb, ${r.attempts}::integer, ${r.workerGroup}::text, ${r.parallelGroup}::text, ${ts(r.wakeAt)}::timestamptz, ${ts(r.enqueuedAt)}::timestamptz, ${ts(r.startedAt)}::timestamptz, ${ts(r.finishedAt)}::timestamptz)`;
872
+ });
873
+ return sql3`UPDATE ${durableStepCheckpoints} AS c SET
874
+ name = v.name, kind = v.kind, step_id = v.step_id, status = v.status, input = v.input,
875
+ output = v.output, error = v.error, events = v.events, attempts = v.attempts,
876
+ worker_group = v.worker_group, parallel_group = v.parallel_group, wake_at = v.wake_at,
877
+ enqueued_at = v.enqueued_at, started_at = v.started_at, finished_at = v.finished_at
878
+ FROM (VALUES ${sql3.join(values, sql3`, `)}) AS v(run_id, seq, name, kind, step_id, status, input,
879
+ output, error, events, attempts, worker_group, parallel_group, wake_at, enqueued_at, started_at,
880
+ finished_at)
881
+ WHERE c.run_id = v.run_id AND c.seq = v.seq AND c.status = 'pending'
882
+ RETURNING c.run_id, c.seq`;
883
+ }
884
+ __name(updateIfPendingFrom, "updateIfPendingFrom");
720
885
  async function upsertCheckpoint(db, cp) {
721
886
  const row = toCheckpointRow(cp);
722
887
  await db.insert(durableStepCheckpoints).values(row).onConflictDoUpdate({
@@ -914,42 +1079,10 @@ __name(fromScheduleRow, "fromScheduleRow");
914
1079
  function excludedSet(table, only) {
915
1080
  return Object.fromEntries(Object.entries(getTableColumns(table)).filter(([key]) => !only || only.includes(key)).map(([key, column]) => [
916
1081
  key,
917
- sql2.raw(`excluded."${column.name}"`)
1082
+ sql3.raw(`excluded."${column.name}"`)
918
1083
  ]));
919
1084
  }
920
1085
  __name(excludedSet, "excludedSet");
921
-
922
- // src/pg/executor.ts
923
- import { sql as sql3 } from "drizzle-orm";
924
- function toDrizzleSql(text2, params = []) {
925
- const chunks = [];
926
- let last = 0;
927
- for (const match of text2.matchAll(/\$(\d+)/g)) {
928
- const index2 = Number(match[1]) - 1;
929
- if (index2 < 0 || index2 >= params.length) throw new Error(`placeholder ${match[0]} has no bound parameter`);
930
- chunks.push(sql3.raw(text2.slice(last, match.index)), sql3`${params[index2]}`);
931
- last = (match.index ?? 0) + match[0].length;
932
- }
933
- chunks.push(sql3.raw(text2.slice(last)));
934
- return sql3.join(chunks);
935
- }
936
- __name(toDrizzleSql, "toDrizzleSql");
937
- function rowsOf(result) {
938
- if (Array.isArray(result)) return result;
939
- return result?.rows ?? [];
940
- }
941
- __name(rowsOf, "rowsOf");
942
- function drizzlePgExecutor(db) {
943
- return {
944
- dialect: "postgres",
945
- escapeId: /* @__PURE__ */ __name((id) => `"${id.replace(/"/g, '""')}"`, "escapeId"),
946
- query: /* @__PURE__ */ __name(async (text2, params) => rowsOf(await db.execute(toDrizzleSql(text2, params))), "query"),
947
- transaction: /* @__PURE__ */ __name((fn) => db.transaction((tx) => fn({
948
- query: /* @__PURE__ */ __name(async (text2, params) => rowsOf(await tx.execute(toDrizzleSql(text2, params))), "query")
949
- })), "transaction")
950
- };
951
- }
952
- __name(drizzlePgExecutor, "drizzlePgExecutor");
953
1086
  export {
954
1087
  DEFAULT_NAMESPACE,
955
1088
  DURABLE_PG_DDL,
@@ -965,6 +1098,7 @@ export {
965
1098
  durableStepCheckpoints,
966
1099
  durableWorkflowRuns,
967
1100
  ensureDrizzlePgDurableSchema,
1101
+ rowsOf,
968
1102
  toDrizzleSql
969
1103
  };
970
1104
  //# sourceMappingURL=index.js.map