@dudousxd/nestjs-durable-store-typeorm 0.21.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.cjs CHANGED
@@ -298,6 +298,18 @@ function durableEntities(options = {}) {
298
298
  name: "StepCheckpointEntity",
299
299
  target: StepCheckpointEntity,
300
300
  tableName: "durable_step_checkpoints",
301
+ // A run's fan, in member order: `checkpointStats`/`queryCheckpoints` narrow by group and page by
302
+ // seq. The DDL that actually creates it lives in `ensureTypeOrmDurableSchema`.
303
+ indices: [
304
+ {
305
+ name: "durable_step_checkpoints_group_idx",
306
+ columns: [
307
+ "runId",
308
+ "parallelGroup",
309
+ "seq"
310
+ ]
311
+ }
312
+ ],
301
313
  columns: {
302
314
  runId: {
303
315
  type: "text",
@@ -840,6 +852,13 @@ async function ensureTypeOrmDurableSchema(dataSource) {
840
852
  "durable_runs_namespace_status_idx",
841
853
  `${runs} (${runsCol("namespace")}, ${runsCol("status")}, ${runsCol("createdAt")})`
842
854
  ],
855
+ // A run's checkpoints by fan, in seq order: the run detail's per-group aggregates and fan
856
+ // pages. `parallel_group` is `varchar(191)` in this DDL (also when added by ALTER above), so
857
+ // MySQL can key it without a prefix length.
858
+ [
859
+ "durable_step_checkpoints_group_idx",
860
+ `${checkpoints} (${cpCol("runId")}, ${cpCol("parallelGroup")}, ${cpCol("seq")})`
861
+ ],
843
862
  // A run's waiters: the `ctx.all` join barrier probes them once per settled child.
844
863
  [
845
864
  "durable_signal_waiters_run_id_idx",
@@ -899,6 +918,7 @@ __name(existingColumns, "existingColumns");
899
918
  // src/typeorm-state-store.ts
900
919
  var import_nestjs_durable_core = require("@dudousxd/nestjs-durable-core");
901
920
  var import_typeorm2 = require("typeorm");
921
+ var CHECKPOINT_NAME_KIND_SQL = "CASE WHEN cp.name LIKE 'signal:child:%' THEN 'child' WHEN cp.name LIKE 'fan:%' THEN 'folded' WHEN cp.name LIKE 'spawn:%' THEN 'spawn' ELSE 'step' END";
902
922
  var sqliteSingletonClaims = /* @__PURE__ */ new WeakMap();
903
923
  var TypeOrmStateStore = class {
904
924
  static {
@@ -1105,20 +1125,16 @@ var TypeOrmStateStore = class {
1105
1125
  ]);
1106
1126
  }
1107
1127
  async saveCheckpointIfPending(checkpoint) {
1108
- const { runId, seq, ...data } = toCheckpointEntity(checkpoint);
1109
- const set = {
1110
- ...data,
1111
- wakeAt: checkpoint.wakeAt != null ? new Date(checkpoint.wakeAt) : () => "NULL"
1112
- };
1113
- for (const key of Object.keys(set)) {
1114
- if (set[key] === void 0) delete set[key];
1115
- }
1116
- const result = await this.checkpoints().createQueryBuilder().update().set(set).where({
1117
- runId,
1118
- seq,
1119
- status: "pending"
1120
- }).execute();
1121
- return (result.affected ?? 0) > 0;
1128
+ return updateIfPending(this.checkpoints(), checkpoint);
1129
+ }
1130
+ async saveCheckpointsIfPending(checkpoints) {
1131
+ if (checkpoints.length === 0) return [];
1132
+ return this.dataSource.transaction(async (em) => {
1133
+ const repo = em.getRepository(StepCheckpointEntity);
1134
+ const wrote = [];
1135
+ for (const checkpoint of checkpoints) wrote.push(await updateIfPending(repo, checkpoint));
1136
+ return wrote;
1137
+ });
1122
1138
  }
1123
1139
  async transaction(work) {
1124
1140
  return this.dataSource.transaction(async (em) => work({
@@ -1440,7 +1456,71 @@ var TypeOrmStateStore = class {
1440
1456
  seq: "ASC"
1441
1457
  }
1442
1458
  });
1443
- return rows.map(fromCheckpointEntity);
1459
+ return rows.filter((r) => prefixes.some((p) => r.name.startsWith(p))).map(fromCheckpointEntity);
1460
+ }
1461
+ /** `GROUP BY parallelGroup, status, nameKind` over one run's checkpoints — one aggregate, so a run
1462
+ * detail draws a wide fan from a few bucket rows instead of loading every member. The `CASE` is
1463
+ * {@link checkpointNameKind} in SQL, and the date aggregates are hydrated through the driver the
1464
+ * way an entity read hydrates the column, so a bucket compares equal to the in-memory reference. */
1465
+ async checkpointStats(runId, opts) {
1466
+ const qb = this.checkpoints().createQueryBuilder("cp").select("cp.parallelGroup", "group").addSelect("cp.status", "status").addSelect(CHECKPOINT_NAME_KIND_SQL, "nameKind").addSelect("COUNT(*)", "count").addSelect("MIN(cp.seq)", "minSeq").addSelect("MAX(cp.seq)", "maxSeq").addSelect("MIN(cp.name)", "minName").addSelect("MAX(cp.name)", "maxName").addSelect("MIN(COALESCE(cp.enqueuedAt, cp.startedAt))", "minEnqueuedAt").addSelect("MIN(cp.startedAt)", "minStartedAt").addSelect("MAX(cp.startedAt)", "maxStartedAt").addSelect("MAX(cp.finishedAt)", "maxFinishedAt").addSelect("SUM(cp.attempts)", "sumAttempts").where("cp.runId = :runId", {
1467
+ runId
1468
+ });
1469
+ if (opts?.group !== void 0) qb.andWhere("cp.parallelGroup = :group", {
1470
+ group: opts.group
1471
+ });
1472
+ const rows = await qb.groupBy("cp.parallelGroup").addGroupBy("cp.status").addGroupBy(CHECKPOINT_NAME_KIND_SQL).getRawMany();
1473
+ const dateColumn = this.dataSource.getMetadata(StepCheckpointEntity).findColumnWithPropertyName("startedAt");
1474
+ const toDate = /* @__PURE__ */ __name((raw) => dateColumn ? this.dataSource.driver.prepareHydratedValue(raw, dateColumn) : new Date(raw), "toDate");
1475
+ return rows.map((r) => ({
1476
+ group: r.group ?? null,
1477
+ status: r.status,
1478
+ nameKind: r.nameKind,
1479
+ count: Number(r.count),
1480
+ minSeq: Number(r.minSeq),
1481
+ maxSeq: Number(r.maxSeq),
1482
+ minName: r.minName,
1483
+ maxName: r.maxName,
1484
+ minEnqueuedAt: toDate(r.minEnqueuedAt),
1485
+ minStartedAt: toDate(r.minStartedAt),
1486
+ maxStartedAt: toDate(r.maxStartedAt),
1487
+ maxFinishedAt: toDate(r.maxFinishedAt),
1488
+ sumAttempts: Number(r.sumAttempts)
1489
+ }));
1490
+ }
1491
+ /** One run's checkpoints matching `query`, in `seq` order and paged in SQL — the read the run
1492
+ * detail uses for a fan's failures, its folded checkpoints and one page of members. An empty
1493
+ * `statuses`/`namePrefixes` matches nothing; an empty `excludeGroups` excludes nothing. */
1494
+ async queryCheckpoints(runId, query) {
1495
+ if (query.statuses?.length === 0 || query.namePrefixes?.length === 0) return [];
1496
+ const qb = this.checkpoints().createQueryBuilder("cp").where("cp.runId = :runId", {
1497
+ runId
1498
+ });
1499
+ if (query.group !== void 0) qb.andWhere("cp.parallelGroup = :group", {
1500
+ group: query.group
1501
+ });
1502
+ if (query.excludeGroups?.length) {
1503
+ qb.andWhere("(cp.parallelGroup IS NULL OR cp.parallelGroup NOT IN (:...excludeGroups))", {
1504
+ excludeGroups: query.excludeGroups
1505
+ });
1506
+ }
1507
+ if (query.statuses) qb.andWhere("cp.status IN (:...statuses)", {
1508
+ statuses: query.statuses
1509
+ });
1510
+ if (query.namePrefixes) {
1511
+ const prefixes = query.namePrefixes.map((p) => `${p.replace(/[!%_]/g, "!$&")}%`);
1512
+ const branches = prefixes.map((_, i) => `cp.name LIKE :namePrefix${i} ESCAPE '!'`);
1513
+ qb.andWhere(`(${branches.join(" OR ")})`, Object.fromEntries(prefixes.map((p, i) => [
1514
+ `namePrefix${i}`,
1515
+ p
1516
+ ])));
1517
+ }
1518
+ qb.orderBy("cp.seq", "ASC");
1519
+ if (query.offset != null) qb.offset(query.offset);
1520
+ if (query.limit != null || query.offset != null) {
1521
+ qb.limit(query.limit ?? Number.MAX_SAFE_INTEGER);
1522
+ }
1523
+ return (await qb.getMany()).map(fromCheckpointEntity);
1444
1524
  }
1445
1525
  async putSignalWaiter(waiter) {
1446
1526
  await this.waiters().save({
@@ -1456,7 +1536,7 @@ var TypeOrmStateStore = class {
1456
1536
  token: (0, import_typeorm2.Like)(`${prefix}%`)
1457
1537
  }
1458
1538
  });
1459
- return rows.map((e) => ({
1539
+ return rows.filter((e) => e.token.startsWith(prefix)).map((e) => ({
1460
1540
  token: e.token,
1461
1541
  runId: e.runId,
1462
1542
  seq: e.seq,
@@ -1747,6 +1827,23 @@ function fromScheduleEntity(e) {
1747
1827
  };
1748
1828
  }
1749
1829
  __name(fromScheduleEntity, "fromScheduleEntity");
1830
+ async function updateIfPending(repo, checkpoint) {
1831
+ const { runId, seq, ...data } = toCheckpointEntity(checkpoint);
1832
+ const set = {
1833
+ ...data,
1834
+ wakeAt: checkpoint.wakeAt != null ? new Date(checkpoint.wakeAt) : () => "NULL"
1835
+ };
1836
+ for (const key of Object.keys(set)) {
1837
+ if (set[key] === void 0) delete set[key];
1838
+ }
1839
+ const result = await repo.createQueryBuilder().update().set(set).where({
1840
+ runId,
1841
+ seq,
1842
+ status: "pending"
1843
+ }).execute();
1844
+ return (result.affected ?? 0) > 0;
1845
+ }
1846
+ __name(updateIfPending, "updateIfPending");
1750
1847
  // Annotate the CommonJS export names for ESM import in node:
1751
1848
  0 && (module.exports = {
1752
1849
  BufferedEventEntity,