@dudousxd/nestjs-durable-store-mikro-orm 0.16.2 → 0.18.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.js CHANGED
@@ -84,11 +84,28 @@ var BufferedSignalEntity = class {
84
84
  token;
85
85
  payload;
86
86
  };
87
+ function namespaceFilterCond(args) {
88
+ if (args.namespace === void 0) return {};
89
+ return {
90
+ namespace: args.namespace
91
+ };
92
+ }
93
+ __name(namespaceFilterCond, "namespaceFilterCond");
87
94
  function durableEntities(options = {}) {
88
95
  const col = columnNamer(options.naming ?? "snake_case");
89
96
  const workflowRuns = new EntitySchema({
90
97
  class: WorkflowRunEntity,
91
98
  tableName: "durable_workflow_runs",
99
+ // Global filter: confines every read to the tenant's namespace when the store is scoped.
100
+ // The `cond` returns `{}` (no-op) when the `namespace` arg is `undefined`, so an unscoped
101
+ // (operator / control-plane) store sees all rows unchanged — existing behaviour is preserved.
102
+ filters: {
103
+ namespace: {
104
+ name: "namespace",
105
+ cond: namespaceFilterCond,
106
+ default: true
107
+ }
108
+ },
92
109
  // Indexes mirror the Prisma adapter so a store swap keeps the same plan. The poller hits these
93
110
  // every tick: `(status, wakeAt)` serves both the due-timer scan (status='suspended' AND wakeAt<=now)
94
111
  // and, via its `status` prefix, the pending/incomplete status lookups; `(workflow, status)` serves
@@ -404,6 +421,7 @@ var ENTITIES = durableEntities();
404
421
  import { attributeColumnFor, attributeOperand, normalizeAttributeRows, parseDuration, sqlComparator } from "@dudousxd/nestjs-durable-core";
405
422
 
406
423
  // src/schema.ts
424
+ import { createHash } from "node:crypto";
407
425
  var DURABLE_TABLE_NAMES = /* @__PURE__ */ new Set([
408
426
  "durable_workflow_runs",
409
427
  "durable_step_checkpoints",
@@ -411,6 +429,14 @@ var DURABLE_TABLE_NAMES = /* @__PURE__ */ new Set([
411
429
  "durable_signal_waiters",
412
430
  "durable_buffered_signals"
413
431
  ]);
432
+ var MARKER_TABLE = "durable_schema_meta";
433
+ var MARKER_ROW_ID = "durable";
434
+ var SCHEMA_LOCK_NAME = "durable_schema";
435
+ var SCHEMA_REVISION = 1;
436
+ function compareStrings(a, b) {
437
+ return a < b ? -1 : a > b ? 1 : 0;
438
+ }
439
+ __name(compareStrings, "compareStrings");
414
440
  function targetTable(statement) {
415
441
  const match = statement.match(/^(?:create\s+(?:unique\s+)?index\s+.+?\s+on\s+|(?:create|alter)\s+table\s+(?:if\s+not\s+exists\s+)?)[`"']?([\w$]+)[`"']?/i);
416
442
  return match?.[1]?.toLowerCase();
@@ -420,7 +446,132 @@ function isRequiredStructure(statement) {
420
446
  return /\b(?:create\s+table|create\s+(?:unique\s+)?index|add\s+(?:column|index|constraint|key|unique|fulltext))\b/i.test(statement);
421
447
  }
422
448
  __name(isRequiredStructure, "isRequiredStructure");
449
+ function detectSqlDialect(orm) {
450
+ const platform = String(orm.em.getPlatform().constructor.name).toLowerCase();
451
+ if (platform.includes("mysql") || platform.includes("maria")) return "mysql";
452
+ if (platform.includes("postgre")) return "postgres";
453
+ if (platform.includes("sqlite") || platform.includes("libsql")) return "sqlite";
454
+ return "unknown";
455
+ }
456
+ __name(detectSqlDialect, "detectSqlDialect");
457
+ function normalizeIndexProperties(properties) {
458
+ if (properties === void 0) return [];
459
+ if (Array.isArray(properties)) return properties.map((property) => String(property));
460
+ return [
461
+ String(properties)
462
+ ];
463
+ }
464
+ __name(normalizeIndexProperties, "normalizeIndexProperties");
465
+ function computeExpectedFingerprint(orm, ownedTableNames) {
466
+ const ownedMetadata = [
467
+ ...orm.getMetadata().getAll().values()
468
+ ].filter((meta) => ownedTableNames.has(meta.tableName)).sort((a, b) => compareStrings(a.tableName, b.tableName));
469
+ const tables = ownedMetadata.map((meta) => {
470
+ const columns = [
471
+ ...meta.props
472
+ ].map((prop) => ({
473
+ prop,
474
+ columnName: prop.fieldNames[0] ?? String(prop.name)
475
+ })).sort((a, b) => compareStrings(a.columnName, b.columnName)).map(({ prop, columnName }) => ({
476
+ name: columnName,
477
+ type: prop.columnTypes[0] ?? String(prop.type),
478
+ nullable: prop.nullable === true,
479
+ primary: prop.primary === true,
480
+ default: prop.default ?? null,
481
+ autoincrement: prop.autoincrement === true
482
+ }));
483
+ const indexes = [
484
+ ...meta.indexes
485
+ ].map((index) => ({
486
+ name: index.name ?? "",
487
+ properties: normalizeIndexProperties(index.properties)
488
+ })).sort((a, b) => compareStrings(a.name, b.name));
489
+ return {
490
+ tableName: meta.tableName,
491
+ columns,
492
+ indexes
493
+ };
494
+ });
495
+ const canonical = `${canonicalize(tables)}|collate=${String(orm.config.get("collate") ?? "")}|rev=${SCHEMA_REVISION}`;
496
+ return createHash("sha256").update(canonical).digest("hex");
497
+ }
498
+ __name(computeExpectedFingerprint, "computeExpectedFingerprint");
499
+ function canonicalize(value) {
500
+ if (Array.isArray(value)) {
501
+ return `[${value.map(canonicalize).join(",")}]`;
502
+ }
503
+ if (value !== null && typeof value === "object") {
504
+ const entries = Object.entries(value).sort(([a], [b]) => compareStrings(a, b));
505
+ return `{${entries.map(([key, val]) => `${JSON.stringify(key)}:${canonicalize(val)}`).join(",")}}`;
506
+ }
507
+ return JSON.stringify(value);
508
+ }
509
+ __name(canonicalize, "canonicalize");
510
+ function createMarkerTableSql() {
511
+ return `create table if not exists ${MARKER_TABLE} (id varchar(32) not null primary key, fingerprint varchar(64) not null, applied_at bigint not null)`;
512
+ }
513
+ __name(createMarkerTableSql, "createMarkerTableSql");
514
+ async function readStoredFingerprint(orm) {
515
+ const rows = await orm.em.getConnection().execute(`select fingerprint from ${MARKER_TABLE} where id = ?`, [
516
+ MARKER_ROW_ID
517
+ ]);
518
+ return rows[0]?.fingerprint ?? void 0;
519
+ }
520
+ __name(readStoredFingerprint, "readStoredFingerprint");
521
+ async function writeFingerprint(orm, dialect, fingerprint) {
522
+ const upsert = dialect === "mysql" ? `insert into ${MARKER_TABLE} (id, fingerprint, applied_at) values (?, ?, ?) on duplicate key update fingerprint = values(fingerprint), applied_at = values(applied_at)` : `insert into ${MARKER_TABLE} (id, fingerprint, applied_at) values (?, ?, ?) on conflict (id) do update set fingerprint = excluded.fingerprint, applied_at = excluded.applied_at`;
523
+ await orm.em.getConnection().execute(upsert, [
524
+ MARKER_ROW_ID,
525
+ fingerprint,
526
+ Date.now()
527
+ ]);
528
+ }
529
+ __name(writeFingerprint, "writeFingerprint");
530
+ async function acquireSchemaLock(orm, dialect) {
531
+ try {
532
+ if (dialect === "mysql") {
533
+ await orm.em.getConnection().execute(`select get_lock('${SCHEMA_LOCK_NAME}', 10)`);
534
+ } else if (dialect === "postgres") {
535
+ await orm.em.getConnection().execute(`select pg_advisory_lock(hashtext('${SCHEMA_LOCK_NAME}'))`);
536
+ }
537
+ } catch (err) {
538
+ const message = err instanceof Error ? err.message : String(err);
539
+ console.warn(`[nestjs-durable-store-mikro-orm] could not acquire the durable-schema advisory lock (proceeding without it): ${message}`);
540
+ }
541
+ }
542
+ __name(acquireSchemaLock, "acquireSchemaLock");
543
+ async function releaseSchemaLock(orm, dialect) {
544
+ try {
545
+ if (dialect === "mysql") {
546
+ await orm.em.getConnection().execute(`select release_lock('${SCHEMA_LOCK_NAME}')`);
547
+ } else if (dialect === "postgres") {
548
+ await orm.em.getConnection().execute(`select pg_advisory_unlock(hashtext('${SCHEMA_LOCK_NAME}'))`);
549
+ }
550
+ } catch {
551
+ }
552
+ }
553
+ __name(releaseSchemaLock, "releaseSchemaLock");
423
554
  async function ensureMikroOrmDurableSchema(orm) {
555
+ const dialect = detectSqlDialect(orm);
556
+ await orm.em.getConnection().execute(createMarkerTableSql());
557
+ const expected = computeExpectedFingerprint(orm, DURABLE_TABLE_NAMES);
558
+ const stored = await readStoredFingerprint(orm);
559
+ if (stored === expected) {
560
+ return;
561
+ }
562
+ await acquireSchemaLock(orm, dialect);
563
+ try {
564
+ if (await readStoredFingerprint(orm) === expected) {
565
+ return;
566
+ }
567
+ await healDurableSchema(orm);
568
+ await writeFingerprint(orm, dialect, expected);
569
+ } finally {
570
+ await releaseSchemaLock(orm, dialect);
571
+ }
572
+ }
573
+ __name(ensureMikroOrmDurableSchema, "ensureMikroOrmDurableSchema");
574
+ async function healDurableSchema(orm) {
424
575
  const sql = await orm.schema.getUpdateSchemaSQL({
425
576
  safe: true
426
577
  });
@@ -442,7 +593,7 @@ async function ensureMikroOrmDurableSchema(orm) {
442
593
  }
443
594
  await alignDurableCollation(orm);
444
595
  }
445
- __name(ensureMikroOrmDurableSchema, "ensureMikroOrmDurableSchema");
596
+ __name(healDurableSchema, "healDurableSchema");
446
597
  function isSafeSqlIdentifier(value) {
447
598
  return /^[A-Za-z0-9_]+$/.test(value);
448
599
  }
@@ -480,25 +631,51 @@ async function alignDurableCollation(orm) {
480
631
  __name(alignDurableCollation, "alignDurableCollation");
481
632
 
482
633
  // src/mikro-orm-state-store.ts
483
- var MikroOrmStateStore = class {
634
+ var MikroOrmStateStore = class _MikroOrmStateStore {
484
635
  static {
485
636
  __name(this, "MikroOrmStateStore");
486
637
  }
487
638
  orm;
488
- constructor(orm) {
639
+ scopeNamespace;
640
+ constructor(orm, opts) {
489
641
  this.orm = orm;
642
+ this.scopeNamespace = opts?.scope?.namespace;
643
+ }
644
+ /**
645
+ * Derive a store confined to a single tenant `scope.namespace`, sharing this store's ORM (no new
646
+ * connection). Turns the operator (unscoped) store into a tenant-boundary view at wiring time —
647
+ * used by the NestJS module's `scopeReads` option, which receives a pre-built store and can't
648
+ * reconstruct it. Pass `{ namespace: undefined }` for the unscoped operator view.
649
+ */
650
+ withScope(scope) {
651
+ return new _MikroOrmStateStore(this.orm, {
652
+ scope
653
+ });
654
+ }
655
+ /**
656
+ * Create a forked EntityManager and activate the `namespace` global filter with the store's
657
+ * scope (if any). All read operations go through this so the tenant boundary is applied
658
+ * uniformly. Write operations (nativeDelete, nativeUpdate, upsert, insertMany) bypass global
659
+ * filters in MikroORM and are unaffected.
660
+ */
661
+ fork() {
662
+ const em = this.orm.em.fork();
663
+ em.setFilterParams("namespace", {
664
+ namespace: this.scopeNamespace
665
+ });
666
+ return em;
490
667
  }
491
668
  async ensureSchema() {
492
669
  await ensureMikroOrmDurableSchema(this.orm);
493
670
  }
494
671
  async createRun(run) {
495
- const em = this.orm.em.fork();
672
+ const em = this.fork();
496
673
  em.create(WorkflowRunEntity, toRunEntity(run));
497
674
  await em.flush();
498
675
  await this.reindexAttributes(run.id, run.searchAttributes);
499
676
  }
500
677
  async updateRun(runId, patch) {
501
- const em = this.orm.em.fork();
678
+ const em = this.fork();
502
679
  const entity = await em.findOneOrFail(WorkflowRunEntity, {
503
680
  id: runId
504
681
  });
@@ -512,7 +689,7 @@ var MikroOrmStateStore = class {
512
689
  /** Rewrite a run's normalized attribute rows: delete the old set, insert the current one. Mirrors
513
690
  * the in-memory store's reindex so the side-table always reflects the run's live searchAttributes. */
514
691
  async reindexAttributes(runId, attributes, forked) {
515
- const em = forked ?? this.orm.em.fork();
692
+ const em = forked ?? this.fork();
516
693
  await em.nativeDelete(RunAttributeEntity, {
517
694
  runId
518
695
  });
@@ -520,14 +697,14 @@ var MikroOrmStateStore = class {
520
697
  if (rows.length) await em.insertMany(RunAttributeEntity, rows);
521
698
  }
522
699
  async getRun(runId) {
523
- const em = this.orm.em.fork();
700
+ const em = this.fork();
524
701
  const entity = await em.findOne(WorkflowRunEntity, {
525
702
  id: runId
526
703
  });
527
704
  return entity ? fromRunEntity(entity) : null;
528
705
  }
529
706
  async deleteRun(runId) {
530
- const em = this.orm.em.fork();
707
+ const em = this.fork();
531
708
  await em.nativeDelete(StepCheckpointEntity, {
532
709
  runId
533
710
  });
@@ -543,7 +720,7 @@ var MikroOrmStateStore = class {
543
720
  }
544
721
  async pruneTerminalRuns(policy, nowMs, limit) {
545
722
  if (policy.statuses.length === 0 || limit <= 0) return 0;
546
- const em = this.orm.em.fork();
723
+ const em = this.fork();
547
724
  const status = {
548
725
  $in: policy.statuses
549
726
  };
@@ -609,7 +786,7 @@ var MikroOrmStateStore = class {
609
786
  return idList.length;
610
787
  }
611
788
  async getCheckpoint(runId, seq) {
612
- const em = this.orm.em.fork();
789
+ const em = this.fork();
613
790
  const entity = await em.findOne(StepCheckpointEntity, {
614
791
  runId,
615
792
  seq
@@ -617,12 +794,12 @@ var MikroOrmStateStore = class {
617
794
  return entity ? fromCheckpointEntity(entity) : null;
618
795
  }
619
796
  async saveCheckpoint(checkpoint) {
620
- const em = this.orm.em.fork();
797
+ const em = this.fork();
621
798
  await em.upsert(StepCheckpointEntity, toCheckpointEntity(checkpoint));
622
799
  await em.flush();
623
800
  }
624
801
  async transaction(work) {
625
- return this.orm.em.fork().transactional(async (em) => work({
802
+ return this.fork().transactional(async (em) => work({
626
803
  raw: em,
627
804
  saveCheckpoint: /* @__PURE__ */ __name(async (cp) => {
628
805
  await em.upsert(StepCheckpointEntity, toCheckpointEntity(cp));
@@ -630,7 +807,7 @@ var MikroOrmStateStore = class {
630
807
  }));
631
808
  }
632
809
  async listIncompleteRuns(namespace) {
633
- const em = this.orm.em.fork();
810
+ const em = this.fork();
634
811
  const rows = await em.find(WorkflowRunEntity, {
635
812
  status: {
636
813
  $in: [
@@ -645,7 +822,7 @@ var MikroOrmStateStore = class {
645
822
  return rows.map(fromRunEntity);
646
823
  }
647
824
  async listPendingRuns(limit, namespace) {
648
- const em = this.orm.em.fork();
825
+ const em = this.fork();
649
826
  const rows = await em.find(WorkflowRunEntity, {
650
827
  status: "pending",
651
828
  ...namespace !== void 0 ? {
@@ -660,7 +837,7 @@ var MikroOrmStateStore = class {
660
837
  return rows.map(fromRunEntity);
661
838
  }
662
839
  async listDueTimers(nowMs, namespace) {
663
- const em = this.orm.em.fork();
840
+ const em = this.fork();
664
841
  const rows = await em.find(WorkflowRunEntity, {
665
842
  status: "suspended",
666
843
  wakeAt: {
@@ -674,7 +851,7 @@ var MikroOrmStateStore = class {
674
851
  return rows.map(fromRunEntity);
675
852
  }
676
853
  async tryLockRun(runId, owner, leaseUntilMs, nowMs) {
677
- const em = this.orm.em.fork();
854
+ const em = this.fork();
678
855
  const affected = await em.nativeUpdate(WorkflowRunEntity, {
679
856
  id: runId,
680
857
  $or: [
@@ -694,7 +871,7 @@ var MikroOrmStateStore = class {
694
871
  return affected === 1;
695
872
  }
696
873
  async releaseRunLock(runId) {
697
- const em = this.orm.em.fork();
874
+ const em = this.fork();
698
875
  await em.nativeUpdate(WorkflowRunEntity, {
699
876
  id: runId
700
877
  }, {
@@ -703,7 +880,7 @@ var MikroOrmStateStore = class {
703
880
  });
704
881
  }
705
882
  async renewRunLock(runId, owner, leaseUntilMs) {
706
- const em = this.orm.em.fork();
883
+ const em = this.fork();
707
884
  const affected = await em.nativeUpdate(WorkflowRunEntity, {
708
885
  id: runId,
709
886
  lockedBy: owner
@@ -713,7 +890,7 @@ var MikroOrmStateStore = class {
713
890
  return affected === 1;
714
891
  }
715
892
  async listRuns(query) {
716
- const em = this.orm.em.fork();
893
+ const em = this.fork();
717
894
  const where = {};
718
895
  if (query.workflow) where.workflow = query.workflow;
719
896
  if (query.namespace !== void 0) where.namespace = query.namespace;
@@ -739,6 +916,7 @@ var MikroOrmStateStore = class {
739
916
  createdAt: "desc"
740
917
  };
741
918
  if (query.tag || query.attributes?.length) {
919
+ if (this.scopeNamespace !== void 0) where.namespace = this.scopeNamespace;
742
920
  const quote = this.idQuote(em);
743
921
  const cols = this.attributeColumns(em);
744
922
  const qb = em.createQueryBuilder(WorkflowRunEntity, "r").where(where).orderBy(orderBy);
@@ -821,7 +999,7 @@ var MikroOrmStateStore = class {
821
999
  return (id) => `${ch}${id}${ch}`;
822
1000
  }
823
1001
  async listCheckpoints(runId) {
824
- const em = this.orm.em.fork();
1002
+ const em = this.fork();
825
1003
  const rows = await em.find(StepCheckpointEntity, {
826
1004
  runId
827
1005
  }, {
@@ -832,7 +1010,7 @@ var MikroOrmStateStore = class {
832
1010
  return rows.map(fromCheckpointEntity);
833
1011
  }
834
1012
  async getLatestCheckpointByName(runId, name) {
835
- const em = this.orm.em.fork();
1013
+ const em = this.fork();
836
1014
  const entity = await em.findOne(StepCheckpointEntity, {
837
1015
  runId,
838
1016
  name
@@ -845,7 +1023,7 @@ var MikroOrmStateStore = class {
845
1023
  }
846
1024
  async listCheckpointsByNamePrefix(runId, prefixes) {
847
1025
  if (prefixes.length === 0) return [];
848
- const em = this.orm.em.fork();
1026
+ const em = this.fork();
849
1027
  const rows = await em.find(StepCheckpointEntity, {
850
1028
  runId,
851
1029
  $or: prefixes.map((p) => ({
@@ -861,7 +1039,7 @@ var MikroOrmStateStore = class {
861
1039
  return rows.map(fromCheckpointEntity);
862
1040
  }
863
1041
  async putSignalWaiter(waiter) {
864
- const em = this.orm.em.fork();
1042
+ const em = this.fork();
865
1043
  await em.upsert(SignalWaiterEntity, {
866
1044
  token: waiter.token,
867
1045
  runId: waiter.runId,
@@ -871,7 +1049,7 @@ var MikroOrmStateStore = class {
871
1049
  await em.flush();
872
1050
  }
873
1051
  async takeSignalWaiter(token) {
874
- const em = this.orm.em.fork();
1052
+ const em = this.fork();
875
1053
  const entity = await em.findOne(SignalWaiterEntity, {
876
1054
  token
877
1055
  });
@@ -886,7 +1064,7 @@ var MikroOrmStateStore = class {
886
1064
  return waiter;
887
1065
  }
888
1066
  async listSignalWaiters(prefix) {
889
- const em = this.orm.em.fork();
1067
+ const em = this.fork();
890
1068
  const rows = await em.find(SignalWaiterEntity, {
891
1069
  token: {
892
1070
  $like: `${prefix}%`
@@ -900,7 +1078,7 @@ var MikroOrmStateStore = class {
900
1078
  }));
901
1079
  }
902
1080
  async bufferSignal(token, payload) {
903
- const em = this.orm.em.fork();
1081
+ const em = this.fork();
904
1082
  const e = new BufferedSignalEntity();
905
1083
  e.token = token;
906
1084
  e.payload = payload ?? null;
@@ -908,7 +1086,7 @@ var MikroOrmStateStore = class {
908
1086
  await em.flush();
909
1087
  }
910
1088
  async takeBufferedSignal(token) {
911
- const em = this.orm.em.fork();
1089
+ const em = this.fork();
912
1090
  const entity = await em.findOne(BufferedSignalEntity, {
913
1091
  token
914
1092
  }, {