@dudousxd/nestjs-durable-store-typeorm 0.17.0 → 0.19.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
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  ENTITIES: () => ENTITIES,
27
27
  JSON_BLOB_COLUMNS: () => JSON_BLOB_COLUMNS,
28
28
  RunAttributeEntity: () => RunAttributeEntity,
29
+ ScheduleEntity: () => ScheduleEntity,
29
30
  SignalWaiterEntity: () => SignalWaiterEntity,
30
31
  StepCheckpointEntity: () => StepCheckpointEntity,
31
32
  TypeOrmStateStore: () => TypeOrmStateStore,
@@ -152,6 +153,23 @@ var BufferedEventEntity = class {
152
153
  payload;
153
154
  publishedAt;
154
155
  };
156
+ var ScheduleEntity = class {
157
+ static {
158
+ __name(this, "ScheduleEntity");
159
+ }
160
+ id;
161
+ namespace;
162
+ workflow;
163
+ paused;
164
+ /** Epoch ms as a `bigint` (drivers hand it back as a string) — an exact integer, so the
165
+ * compare-and-set in `updateSchedule` can't be defeated by a DATETIME's truncated milliseconds. */
166
+ nextFireAt;
167
+ tags;
168
+ spec;
169
+ state;
170
+ createdAt;
171
+ updatedAt;
172
+ };
155
173
  function durableEntities(options = {}) {
156
174
  const col = columnNamer(options.naming ?? "snake_case");
157
175
  const workflowRuns = new import_typeorm.EntitySchema({
@@ -479,13 +497,69 @@ function durableEntities(options = {}) {
479
497
  }
480
498
  }
481
499
  });
500
+ const schedules = new import_typeorm.EntitySchema({
501
+ name: "ScheduleEntity",
502
+ target: ScheduleEntity,
503
+ tableName: "durable_schedules",
504
+ columns: {
505
+ id: {
506
+ type: "text",
507
+ primary: true,
508
+ name: col("id")
509
+ },
510
+ namespace: {
511
+ type: "text",
512
+ name: col("namespace"),
513
+ default: "default"
514
+ },
515
+ workflow: {
516
+ type: "text",
517
+ name: col("workflow")
518
+ },
519
+ paused: {
520
+ type: Boolean,
521
+ name: col("paused"),
522
+ default: false
523
+ },
524
+ nextFireAt: {
525
+ type: "bigint",
526
+ nullable: true,
527
+ name: col("nextFireAt")
528
+ },
529
+ tags: {
530
+ type: "text",
531
+ nullable: true,
532
+ name: col("tags"),
533
+ transformer: jsonColumnTransformer("schedules.tags")
534
+ },
535
+ spec: {
536
+ type: "text",
537
+ name: col("spec"),
538
+ transformer: jsonColumnTransformer("schedules.spec")
539
+ },
540
+ state: {
541
+ type: "text",
542
+ name: col("state"),
543
+ transformer: jsonColumnTransformer("schedules.state")
544
+ },
545
+ createdAt: {
546
+ type: Date,
547
+ name: col("createdAt")
548
+ },
549
+ updatedAt: {
550
+ type: Date,
551
+ name: col("updatedAt")
552
+ }
553
+ }
554
+ });
482
555
  return [
483
556
  workflowRuns,
484
557
  stepCheckpoints,
485
558
  runAttributes,
486
559
  signalWaiters,
487
560
  bufferedSignals,
488
- bufferedEvents
561
+ bufferedEvents,
562
+ schedules
489
563
  ];
490
564
  }
491
565
  __name(durableEntities, "durableEntities");
@@ -513,7 +587,8 @@ var TABLE_TO_ENTITY = {
513
587
  durable_run_attributes: RunAttributeEntity,
514
588
  durable_signal_waiters: SignalWaiterEntity,
515
589
  durable_buffered_signals: BufferedSignalEntity,
516
- durable_buffered_events: BufferedEventEntity
590
+ durable_buffered_events: BufferedEventEntity,
591
+ durable_schedules: ScheduleEntity
517
592
  };
518
593
  function durableManagedTables() {
519
594
  return Object.keys(TABLE_TO_ENTITY);
@@ -571,6 +646,8 @@ async function ensureTypeOrmDurableSchema(dataSource) {
571
646
  const waiterCol = /* @__PURE__ */ __name((property) => q(resolve("durable_signal_waiters", property)), "waiterCol");
572
647
  const bufCol = /* @__PURE__ */ __name((property) => q(resolve("durable_buffered_signals", property)), "bufCol");
573
648
  const bevCol = /* @__PURE__ */ __name((property) => q(resolve("durable_buffered_events", property)), "bevCol");
649
+ const schedules = q("durable_schedules");
650
+ const schCol = /* @__PURE__ */ __name((property) => q(resolve("durable_schedules", property)), "schCol");
574
651
  const num = isPg ? "double precision" : isMysql ? "double" : "real";
575
652
  const bufferedId = isSqlite ? `${bufCol("id")} integer PRIMARY KEY AUTOINCREMENT` : isMysql ? `${bufCol("id")} bigint NOT NULL AUTO_INCREMENT PRIMARY KEY` : isPg ? `${bufCol("id")} bigserial PRIMARY KEY` : `${bufCol("id")} bigint NOT NULL AUTO_INCREMENT PRIMARY KEY`;
576
653
  const tables = [
@@ -614,6 +691,16 @@ async function ensureTypeOrmDurableSchema(dataSource) {
614
691
  `CREATE TABLE IF NOT EXISTS ${bufferedEvents} (
615
692
  ${bevCol("id")} ${str} PRIMARY KEY, ${bevCol("name")} ${str} NOT NULL,
616
693
  ${bevCol("payload")} ${txt}, ${bevCol("publishedAt")} ${ts} NOT NULL
694
+ )`,
695
+ // Persisted schedules. `next_fire_at` is epoch ms as a bigint (exact, so the compare-and-set on
696
+ // it survives MySQL's DATETIME millisecond truncation); `spec`/`state` are engine-owned JSON.
697
+ `CREATE TABLE IF NOT EXISTS ${schedules} (
698
+ ${schCol("id")} ${str} PRIMARY KEY, ${schCol("namespace")} ${nsCol},
699
+ ${schCol("workflow")} ${str} NOT NULL,
700
+ ${schCol("paused")} boolean NOT NULL DEFAULT ${isPg ? "false" : "0"},
701
+ ${schCol("nextFireAt")} bigint, ${schCol("tags")} ${txt},
702
+ ${schCol("spec")} ${txt} NOT NULL, ${schCol("state")} ${txt} NOT NULL,
703
+ ${schCol("createdAt")} ${ts} NOT NULL, ${schCol("updatedAt")} ${ts} NOT NULL
617
704
  )`
618
705
  ];
619
706
  const additive = {
@@ -757,6 +844,11 @@ async function ensureTypeOrmDurableSchema(dataSource) {
757
844
  ],
758
845
  // Search-attribute pushdown: equality + range predicates probe by (key, value). Two composite
759
846
  // indexes — one per typed column — so a numeric range or a string equality is an index scan.
847
+ // Persisted schedules: the poll's "due by T" scan (`paused = false AND next_fire_at <= T`).
848
+ [
849
+ "durable_schedules_due_idx",
850
+ `${schedules} (${schCol("paused")}, ${schCol("nextFireAt")})`
851
+ ],
760
852
  [
761
853
  "durable_run_attributes_num_idx",
762
854
  `${runAttributes} (${attrCol("key")}, ${attrCol("numValue")})`
@@ -823,6 +915,9 @@ var TypeOrmStateStore = class {
823
915
  attributes() {
824
916
  return this.dataSource.getRepository(RunAttributeEntity);
825
917
  }
918
+ schedules() {
919
+ return this.dataSource.getRepository(ScheduleEntity);
920
+ }
826
921
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
827
922
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
828
923
  async reindexAttributes(runId, attributes, em) {
@@ -883,6 +978,52 @@ var TypeOrmStateStore = class {
883
978
  id: runId
884
979
  });
885
980
  }
981
+ async deleteRuns(runIds) {
982
+ if (runIds.length === 0) return;
983
+ await this.dataSource.transaction(async (em) => {
984
+ await em.getRepository(StepCheckpointEntity).delete({
985
+ runId: (0, import_typeorm2.In)(runIds)
986
+ });
987
+ await em.getRepository(SignalWaiterEntity).delete({
988
+ runId: (0, import_typeorm2.In)(runIds)
989
+ });
990
+ await em.getRepository(RunAttributeEntity).delete({
991
+ runId: (0, import_typeorm2.In)(runIds)
992
+ });
993
+ await em.getRepository(WorkflowRunEntity).delete({
994
+ id: (0, import_typeorm2.In)(runIds)
995
+ });
996
+ });
997
+ }
998
+ async pruneTerminalRuns(policy, nowMs, limit) {
999
+ const statuses = policy.statuses.filter((s) => import_nestjs_durable_core.TERMINAL_RUN_STATUSES.includes(s));
1000
+ if (statuses.length === 0 || limit <= 0) return 0;
1001
+ if (policy.maxAge == null && policy.maxCount == null) return 0;
1002
+ const scoped = /* @__PURE__ */ __name(() => this.runQueryBuilder({
1003
+ ...policy.scope ?? {},
1004
+ statuses
1005
+ }).select("r.id", "id"), "scoped");
1006
+ const ids = /* @__PURE__ */ new Set();
1007
+ if (policy.maxAge != null) {
1008
+ const cutoff = new Date(nowMs - (0, import_nestjs_durable_core.parseDuration)(policy.maxAge));
1009
+ const rows = await scoped().andWhere("r.updatedAt < :cutoff", {
1010
+ cutoff
1011
+ }).orderBy("r.updatedAt", "ASC").limit(limit).getRawMany();
1012
+ for (const r of rows) ids.add(r.id);
1013
+ }
1014
+ if (policy.maxCount != null && ids.size < limit) {
1015
+ const rows = await scoped().orderBy("r.updatedAt", "DESC").addOrderBy("r.id", "DESC").limit(limit).offset(policy.maxCount).getRawMany();
1016
+ for (const r of rows) {
1017
+ if (ids.size >= limit) break;
1018
+ ids.add(r.id);
1019
+ }
1020
+ }
1021
+ const doomed = [
1022
+ ...ids
1023
+ ].slice(0, limit);
1024
+ await this.deleteRuns(doomed);
1025
+ return doomed.length;
1026
+ }
886
1027
  async getCheckpoint(runId, seq) {
887
1028
  const e = await this.checkpoints().findOneBy({
888
1029
  runId,
@@ -987,13 +1128,28 @@ var TypeOrmStateStore = class {
987
1128
  if (query.workflow) qb.andWhere("r.workflow = :workflow", {
988
1129
  workflow: query.workflow
989
1130
  });
1131
+ if (query.workflows) qb.andWhere(query.workflows.length ? "r.workflow IN (:...workflows)" : "1 = 0", query.workflows.length ? {
1132
+ workflows: query.workflows
1133
+ } : {});
990
1134
  if (query.namespace !== void 0) qb.andWhere("r.namespace = :namespace", {
991
1135
  namespace: query.namespace
992
1136
  });
1137
+ if (query.namespaces) qb.andWhere(query.namespaces.length ? "r.namespace IN (:...namespaces)" : "1 = 0", query.namespaces.length ? {
1138
+ namespaces: query.namespaces
1139
+ } : {});
993
1140
  if (query.origin === null) qb.andWhere("r.origin IS NULL");
994
1141
  else if (query.origin !== void 0) qb.andWhere("r.origin = :origin", {
995
1142
  origin: query.origin
996
1143
  });
1144
+ if (query.origins) {
1145
+ const named = query.origins.filter((o) => o !== null);
1146
+ const branches = [];
1147
+ if (named.length) branches.push("r.origin IN (:...origins)");
1148
+ if (query.origins.some((o) => o === null)) branches.push("r.origin IS NULL");
1149
+ qb.andWhere(branches.length ? `(${branches.join(" OR ")})` : "1 = 0", named.length ? {
1150
+ origins: named
1151
+ } : {});
1152
+ }
997
1153
  if (query.status) qb.andWhere("r.status = :status", {
998
1154
  status: query.status
999
1155
  });
@@ -1003,6 +1159,13 @@ var TypeOrmStateStore = class {
1003
1159
  if (query.tag) qb.andWhere("r.tags LIKE :tagPattern", {
1004
1160
  tagPattern: `%"${query.tag}"%`
1005
1161
  });
1162
+ if (query.tags) {
1163
+ const branches = query.tags.map((_, i) => `r.tags LIKE :tagPattern${i}`);
1164
+ qb.andWhere(branches.length ? `(${branches.join(" OR ")})` : "1 = 0", Object.fromEntries(query.tags.map((t, i) => [
1165
+ `tagPattern${i}`,
1166
+ `%"${t}"%`
1167
+ ])));
1168
+ }
1006
1169
  if (query.attributes?.length) {
1007
1170
  query.attributes.forEach((f, i) => this.applyAttributeExists(qb, f, i));
1008
1171
  }
@@ -1016,6 +1179,9 @@ var TypeOrmStateStore = class {
1016
1179
  const rows = await qb.getMany();
1017
1180
  return rows.map(fromRunEntity);
1018
1181
  }
1182
+ async countRuns(query) {
1183
+ return this.runQueryBuilder(query).getCount();
1184
+ }
1019
1185
  /** `GROUP BY status, origin` over the same predicates {@link listRuns} pages — one aggregate, so a
1020
1186
  * console can show whole-set counts next to a bounded page instead of downloading every run to
1021
1187
  * count them in the browser. */
@@ -1026,33 +1192,121 @@ var TypeOrmStateStore = class {
1026
1192
  count: Number(r.count)
1027
1193
  })));
1028
1194
  }
1195
+ /** Distinct values of one filter axis over the same predicates {@link listRuns} pages. A run-table
1196
+ * column is a `GROUP BY` like {@link runFacets}, exact over the whole matching set; the tag and
1197
+ * attribute axes live outside the row (json text / side table) and are counted from a bounded page
1198
+ * of runs instead — see `RunValueFacetOptions.scan`. */
1199
+ async runValueFacets(axis, query, opts) {
1200
+ if (!(0, import_nestjs_durable_core.axisIsRunColumn)(axis)) {
1201
+ const runs = await this.listRuns({
1202
+ ...query,
1203
+ limit: opts?.scan ?? import_nestjs_durable_core.RUN_VALUE_FACET_SCAN
1204
+ });
1205
+ return (0, import_nestjs_durable_core.runValueFacetsFromRuns)(runs, axis, opts);
1206
+ }
1207
+ const rows = await this.runQueryBuilder(query).select(`r.${axis.field}`, "value").addSelect("COUNT(*)", "count").groupBy(`r.${axis.field}`).getRawMany();
1208
+ return (0, import_nestjs_durable_core.mergeRunValueFacetRows)(rows.map((r) => ({
1209
+ value: r.value ?? null,
1210
+ count: Number(r.count)
1211
+ })), opts);
1212
+ }
1029
1213
  /** Add one attribute predicate to `qb` as an EXISTS subquery on the side-table. `i` namespaces the
1030
1214
  * bind params so multiple ANDed filters don't collide. */
1031
1215
  applyAttributeExists(qb, f, i) {
1032
- const valueProperty = (0, import_nestjs_durable_core.attributeColumnFor)(f);
1033
- const cmp = (0, import_nestjs_durable_core.sqlComparator)(f.op);
1216
+ const comparisons = (0, import_nestjs_durable_core.attributePredicateOperands)(f);
1034
1217
  const keyParam = `attrKey${i}`;
1035
- const valParam = `attrVal${i}`;
1036
1218
  const resolve = durableColumnResolver(this.dataSource);
1037
1219
  const q = this.idQuote();
1038
1220
  const wrap = /* @__PURE__ */ __name((name) => `${q}${name}${q}`, "wrap");
1039
1221
  const runIdCol = wrap(resolve("durable_run_attributes", "runId"));
1040
1222
  const keyCol = wrap(resolve("durable_run_attributes", "key"));
1041
- const valueCol = wrap(resolve("durable_run_attributes", valueProperty));
1042
1223
  const runPkCol = wrap(resolve("durable_workflow_runs", "id"));
1043
1224
  const table = wrap("durable_run_attributes");
1044
1225
  const a = `a${i}`;
1045
- const sql = `EXISTS (SELECT 1 FROM ${table} ${a} WHERE ${a}.${runIdCol} = r.${runPkCol} AND ${a}.${keyCol} = :${keyParam} AND ${a}.${valueCol} ${cmp} :${valParam})`;
1046
- qb.andWhere(sql, {
1047
- [keyParam]: f.key,
1048
- [valParam]: (0, import_nestjs_durable_core.attributeOperand)(f)
1226
+ const params = {
1227
+ [keyParam]: f.key
1228
+ };
1229
+ const branches = comparisons.map(({ column, comparator, operand }, j) => {
1230
+ const valParam = `attrVal${i}_${j}`;
1231
+ params[valParam] = operand;
1232
+ return `${a}.${wrap(resolve("durable_run_attributes", column))} ${comparator} :${valParam}`;
1049
1233
  });
1234
+ if (branches.length === 0) {
1235
+ qb.andWhere("1 = 0");
1236
+ return;
1237
+ }
1238
+ const match = branches.length === 1 ? branches[0] : `(${branches.join(" OR ")})`;
1239
+ const sql = `EXISTS (SELECT 1 FROM ${table} ${a} WHERE ${a}.${runIdCol} = r.${runPkCol} AND ${a}.${keyCol} = :${keyParam} AND ${match})`;
1240
+ qb.andWhere(sql, params);
1050
1241
  }
1051
1242
  /** The identifier quote char for the active driver (MySQL backtick, others double-quote). */
1052
1243
  idQuote() {
1053
1244
  const type = String(this.dataSource.options.type);
1054
1245
  return type === "mysql" || type === "mariadb" || type === "aurora-mysql" ? "`" : '"';
1055
1246
  }
1247
+ // ---- persisted schedules ----------------------------------------------------------------------
1248
+ async saveSchedule(record) {
1249
+ await this.schedules().save(toScheduleEntity(record));
1250
+ }
1251
+ async getSchedule(id) {
1252
+ const e = await this.schedules().findOneBy({
1253
+ id
1254
+ });
1255
+ return e ? fromScheduleEntity(e) : null;
1256
+ }
1257
+ /** One conditional UPDATE: the compare-and-set on `next_fire_at` (an exact bigint) is atomic. */
1258
+ async updateSchedule(id, patch, expectedNextFireAt) {
1259
+ const set = {};
1260
+ if (patch.namespace !== void 0) set.namespace = patch.namespace;
1261
+ if (patch.workflow !== void 0) set.workflow = patch.workflow;
1262
+ if (patch.paused !== void 0) set.paused = patch.paused;
1263
+ if ("nextFireAt" in patch) set.nextFireAt = patch.nextFireAt ?? null;
1264
+ if ("tags" in patch) set.tags = patch.tags ?? null;
1265
+ if (patch.spec !== void 0) set.spec = patch.spec;
1266
+ if (patch.state !== void 0) set.state = patch.state;
1267
+ if (patch.updatedAt !== void 0) set.updatedAt = patch.updatedAt;
1268
+ if (!Object.keys(set).length) return await this.getSchedule(id) !== null;
1269
+ const qb = this.schedules().createQueryBuilder().update().set(set).where({
1270
+ id
1271
+ });
1272
+ if (expectedNextFireAt === null) qb.andWhere({
1273
+ nextFireAt: (0, import_typeorm2.IsNull)()
1274
+ });
1275
+ else if (expectedNextFireAt !== void 0) qb.andWhere({
1276
+ nextFireAt: expectedNextFireAt
1277
+ });
1278
+ const result = await qb.execute();
1279
+ return (result.affected ?? 0) > 0;
1280
+ }
1281
+ async deleteSchedule(id) {
1282
+ const result = await this.schedules().delete({
1283
+ id
1284
+ });
1285
+ return (result.affected ?? 0) > 0;
1286
+ }
1287
+ async listSchedules(query) {
1288
+ const qb = this.schedules().createQueryBuilder("s");
1289
+ if (query.namespace !== void 0) qb.andWhere("s.namespace = :namespace", {
1290
+ namespace: query.namespace
1291
+ });
1292
+ if (query.workflow !== void 0) qb.andWhere("s.workflow = :workflow", {
1293
+ workflow: query.workflow
1294
+ });
1295
+ if (query.paused !== void 0) qb.andWhere("s.paused = :paused", {
1296
+ paused: query.paused
1297
+ });
1298
+ if (query.tag !== void 0) qb.andWhere("s.tags LIKE :tag", {
1299
+ tag: `%"${query.tag}"%`
1300
+ });
1301
+ if (query.dueBy !== void 0) qb.andWhere("s.paused = :notPaused AND s.nextFireAt <= :dueBy", {
1302
+ notPaused: false,
1303
+ dueBy: query.dueBy
1304
+ });
1305
+ qb.orderBy("CASE WHEN s.nextFireAt IS NULL THEN 1 ELSE 0 END", "ASC").addOrderBy("s.nextFireAt", "ASC").addOrderBy("s.id", "ASC");
1306
+ if (query.limit != null) qb.take(query.limit);
1307
+ if (query.offset != null) qb.skip(query.offset);
1308
+ return (await qb.getMany()).map(fromScheduleEntity);
1309
+ }
1056
1310
  async listCheckpoints(runId) {
1057
1311
  const rows = await this.checkpoints().find({
1058
1312
  where: {
@@ -1296,6 +1550,39 @@ function fromCheckpointEntity(e) {
1296
1550
  };
1297
1551
  }
1298
1552
  __name(fromCheckpointEntity, "fromCheckpointEntity");
1553
+ function toScheduleEntity(r) {
1554
+ const e = new ScheduleEntity();
1555
+ e.id = r.id;
1556
+ e.namespace = r.namespace;
1557
+ e.workflow = r.workflow;
1558
+ e.paused = r.paused;
1559
+ e.nextFireAt = r.nextFireAt;
1560
+ e.tags = r.tags ?? null;
1561
+ e.spec = r.spec;
1562
+ e.state = r.state;
1563
+ e.createdAt = r.createdAt;
1564
+ e.updatedAt = r.updatedAt;
1565
+ return e;
1566
+ }
1567
+ __name(toScheduleEntity, "toScheduleEntity");
1568
+ function fromScheduleEntity(e) {
1569
+ return {
1570
+ id: e.id,
1571
+ namespace: e.namespace,
1572
+ workflow: e.workflow,
1573
+ // SQLite/MySQL hand a boolean column back as 0/1.
1574
+ paused: Boolean(e.paused),
1575
+ nextFireAt: e.nextFireAt == null ? null : Number(e.nextFireAt),
1576
+ ...e.tags ? {
1577
+ tags: e.tags
1578
+ } : {},
1579
+ spec: e.spec ?? {},
1580
+ state: e.state ?? {},
1581
+ createdAt: e.createdAt,
1582
+ updatedAt: e.updatedAt
1583
+ };
1584
+ }
1585
+ __name(fromScheduleEntity, "fromScheduleEntity");
1299
1586
  // Annotate the CommonJS export names for ESM import in node:
1300
1587
  0 && (module.exports = {
1301
1588
  BufferedEventEntity,
@@ -1303,6 +1590,7 @@ __name(fromCheckpointEntity, "fromCheckpointEntity");
1303
1590
  ENTITIES,
1304
1591
  JSON_BLOB_COLUMNS,
1305
1592
  RunAttributeEntity,
1593
+ ScheduleEntity,
1306
1594
  SignalWaiterEntity,
1307
1595
  StepCheckpointEntity,
1308
1596
  TypeOrmStateStore,