@nodii/saga 0.0.1 → 0.2.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.
Files changed (90) hide show
  1. package/dist/admin-service.d.ts +55 -0
  2. package/dist/admin-service.d.ts.map +1 -0
  3. package/dist/admin-service.js +161 -0
  4. package/dist/admin-service.js.map +1 -0
  5. package/dist/async-step.d.ts +43 -0
  6. package/dist/async-step.d.ts.map +1 -0
  7. package/dist/async-step.js +166 -0
  8. package/dist/async-step.js.map +1 -0
  9. package/dist/context.d.ts +18 -0
  10. package/dist/context.d.ts.map +1 -0
  11. package/dist/context.js +28 -0
  12. package/dist/context.js.map +1 -0
  13. package/dist/decorator.d.ts +24 -0
  14. package/dist/decorator.d.ts.map +1 -0
  15. package/dist/decorator.js +69 -0
  16. package/dist/decorator.js.map +1 -0
  17. package/dist/idempotency.d.ts +22 -0
  18. package/dist/idempotency.d.ts.map +1 -0
  19. package/dist/idempotency.js +44 -0
  20. package/dist/idempotency.js.map +1 -0
  21. package/dist/index.d.ts +20 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +34 -4
  24. package/dist/index.js.map +1 -1
  25. package/dist/init.d.ts +45 -0
  26. package/dist/init.d.ts.map +1 -0
  27. package/dist/init.js +86 -0
  28. package/dist/init.js.map +1 -0
  29. package/dist/interceptor.d.ts +5 -0
  30. package/dist/interceptor.d.ts.map +1 -0
  31. package/dist/interceptor.js +34 -0
  32. package/dist/interceptor.js.map +1 -0
  33. package/dist/migrations/index.d.ts +19 -0
  34. package/dist/migrations/index.d.ts.map +1 -0
  35. package/dist/migrations/index.js +106 -0
  36. package/dist/migrations/index.js.map +1 -0
  37. package/dist/reaper.d.ts +27 -0
  38. package/dist/reaper.d.ts.map +1 -0
  39. package/dist/reaper.js +79 -0
  40. package/dist/reaper.js.map +1 -0
  41. package/dist/registry.d.ts +7 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +23 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/saga.d.ts +48 -0
  46. package/dist/saga.d.ts.map +1 -0
  47. package/dist/saga.js +384 -0
  48. package/dist/saga.js.map +1 -0
  49. package/dist/signal-bus/index.d.ts +2 -0
  50. package/dist/signal-bus/index.d.ts.map +1 -0
  51. package/dist/signal-bus/index.js +6 -0
  52. package/dist/signal-bus/index.js.map +1 -0
  53. package/dist/signal-bus/redis-stream.d.ts +43 -0
  54. package/dist/signal-bus/redis-stream.d.ts.map +1 -0
  55. package/dist/signal-bus/redis-stream.js +189 -0
  56. package/dist/signal-bus/redis-stream.js.map +1 -0
  57. package/dist/signals.d.ts +29 -0
  58. package/dist/signals.d.ts.map +1 -0
  59. package/dist/signals.js +113 -0
  60. package/dist/signals.js.map +1 -0
  61. package/dist/state-store/index.d.ts +2 -0
  62. package/dist/state-store/index.d.ts.map +1 -0
  63. package/dist/state-store/index.js +6 -0
  64. package/dist/state-store/index.js.map +1 -0
  65. package/dist/state-store/postgres.d.ts +47 -0
  66. package/dist/state-store/postgres.d.ts.map +1 -0
  67. package/dist/state-store/postgres.js +270 -0
  68. package/dist/state-store/postgres.js.map +1 -0
  69. package/dist/test-doubles/in-memory-signal-bus.d.ts +38 -0
  70. package/dist/test-doubles/in-memory-signal-bus.d.ts.map +1 -0
  71. package/dist/test-doubles/in-memory-signal-bus.js +68 -0
  72. package/dist/test-doubles/in-memory-signal-bus.js.map +1 -0
  73. package/dist/test-doubles/in-memory-state-store.d.ts +26 -0
  74. package/dist/test-doubles/in-memory-state-store.d.ts.map +1 -0
  75. package/dist/test-doubles/in-memory-state-store.js +87 -0
  76. package/dist/test-doubles/in-memory-state-store.js.map +1 -0
  77. package/dist/test-doubles/index.d.ts +4 -0
  78. package/dist/test-doubles/index.d.ts.map +1 -0
  79. package/dist/test-doubles/index.js +14 -0
  80. package/dist/test-doubles/index.js.map +1 -0
  81. package/dist/types.d.ts +260 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +103 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/uuid.d.ts +3 -0
  86. package/dist/uuid.d.ts.map +1 -0
  87. package/dist/uuid.js +62 -0
  88. package/dist/uuid.js.map +1 -0
  89. package/package.json +33 -7
  90. package/src/migrations/001-saga-state.sql +79 -0
@@ -0,0 +1,44 @@
1
+ // Idempotency shim.
2
+ //
3
+ // `@nodii/saga` v0.1.0 does NOT compute idempotency keys itself — per § 5.4 +
4
+ // Q7 resolution the canonical key form `sha256(saga_id + step_name +
5
+ // serialized_input)` lives in `@nodii/idempotency`. Until that library lands
6
+ // at v0.1.0 (next implementation slot in the DAG), this module ships two
7
+ // in-process shims that exercise the same contract:
8
+ //
9
+ // - `NoopIdempotency` — always runs the handler; no caching.
10
+ // - `InMemoryIdempotency` — caches by the locked key form (sha256 hex).
11
+ // Cached result is returned for subsequent calls with the same key.
12
+ import { createHash } from "node:crypto";
13
+ export class NoopIdempotency {
14
+ async wrapForSagaStep(args) {
15
+ return args.handler();
16
+ }
17
+ }
18
+ export class InMemoryIdempotency {
19
+ cache = new Map();
20
+ async wrapForSagaStep(args) {
21
+ const key = computeKey(args.sagaId, args.stepName, args.serializedInput);
22
+ if (this.cache.has(key)) {
23
+ return this.cache.get(key);
24
+ }
25
+ const result = await args.handler();
26
+ this.cache.set(key, result);
27
+ return result;
28
+ }
29
+ /** Test-only — used by the parity-fence fixtures to assert byte equality. */
30
+ static debugComputeKey(sagaId, stepName, serializedInput) {
31
+ return computeKey(sagaId, stepName, serializedInput);
32
+ }
33
+ reset() {
34
+ this.cache.clear();
35
+ }
36
+ }
37
+ function computeKey(sagaId, stepName, serializedInput) {
38
+ return createHash("sha256")
39
+ .update(sagaId)
40
+ .update(stepName)
41
+ .update(serializedInput)
42
+ .digest("hex");
43
+ }
44
+ //# sourceMappingURL=idempotency.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"idempotency.js","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAAA,oBAAoB;AACpB,EAAE;AACF,8EAA8E;AAC9E,qEAAqE;AACrE,6EAA6E;AAC7E,yEAAyE;AACzE,oDAAoD;AACpD,EAAE;AACF,+DAA+D;AAC/D,0EAA0E;AAC1E,wEAAwE;AAExE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,MAAM,OAAO,eAAe;IAC1B,KAAK,CAAC,eAAe,CAAI,IAKxB;QACC,OAAO,IAAI,CAAC,OAAO,EAAE,CAAC;IACxB,CAAC;CACF;AAED,MAAM,OAAO,mBAAmB;IACtB,KAAK,GAAG,IAAI,GAAG,EAAmB,CAAC;IAE3C,KAAK,CAAC,eAAe,CAAI,IAKxB;QACC,MAAM,GAAG,GAAG,UAAU,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;QACzE,IAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACxB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAM,CAAC;QAClC,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QACpC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;QAC5B,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,6EAA6E;IAC7E,MAAM,CAAC,eAAe,CACpB,MAAc,EACd,QAAgB,EAChB,eAAuB;QAEvB,OAAO,UAAU,CAAC,MAAM,EAAE,QAAQ,EAAE,eAAe,CAAC,CAAC;IACvD,CAAC;IAED,KAAK;QACH,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;CACF;AAED,SAAS,UAAU,CACjB,MAAc,EACd,QAAgB,EAChB,eAAuB;IAEvB,OAAO,UAAU,CAAC,QAAQ,CAAC;SACxB,MAAM,CAAC,MAAM,CAAC;SACd,MAAM,CAAC,QAAQ,CAAC;SAChB,MAAM,CAAC,eAAe,CAAC;SACvB,MAAM,CAAC,KAAK,CAAC,CAAC;AACnB,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,22 @@
1
1
  export declare const LIB_NAME = "saga";
2
- export declare const VERSION = "0.0.1";
2
+ export declare const VERSION = "0.1.1";
3
+ export { initSaga, getSagaOrNull, _resetForTests } from "./init";
4
+ export type { InitSagaConfig, ResolvedSagaConfig } from "./init";
5
+ export { createSaga, withSaga, step, stepParallel, cancel, currentSaga, } from "./saga";
6
+ export { registerSaga, getRegisteredSaga, hasRegisteredSaga, _resetRegistryForTests, } from "./registry";
7
+ export { emitSagaSignal, awaitSagaSignal, awaitChildren, awaitAnySibling, getSiblings, } from "./signals";
8
+ export { sagaCallable, SAGA_CALLABLE_METADATA, readSagaCallableConfig, readSagaCallableRegistry, _resetDecoratorRegistryForTests, } from "./decorator";
9
+ export type { SagaCallableMethod } from "./decorator";
10
+ export { sagaContext } from "./interceptor";
11
+ export { createSagaAdminServer } from "./admin-service";
12
+ export type { CancelSagaRequest, ForceCompensateRequest, GetSagaRequest, ListSagasRequest, MarkManuallyCompensatedRequest, RetryStepRequest, SagaAdminServer, SkipStepRequest, WriteRpcRequest, } from "./admin-service";
13
+ export { PostgresSagaStateStore, type PostgresSagaStateStoreOpts, } from "./state-store/postgres";
14
+ export { RedisSignalBus, type RedisSignalBusOpts, } from "./signal-bus/redis-stream";
15
+ export { beginAsyncStep, completeAsyncStep, getCompletedAsyncOutput, SagaPausedSentinel, } from "./async-step";
16
+ export { startSagaReaper } from "./reaper";
17
+ export type { SagaReaperOpts, SagaReaperHandle } from "./reaper";
18
+ export { applySagaMigrations, getSagaStateMigrationSQL } from "./migrations";
19
+ export { uuidv7, isUuidv7 } from "./uuid";
20
+ export { MIN_JUSTIFICATION_CHARS, SAGA_ADMIN_PERMISSIONS, SagaError, SagaNotInitialized, SagaContextRequired, SagaStepFailed, SagaCompensationFailed, JustificationRequired, SagaTypeNotRegistered, NoActiveSaga, NotImplementedError, } from "./types";
21
+ export type { CompensationLogEntry, CreateSagaOpts, IdempotencyShim, ResumeContext, SagaAdminPermission, SagaAdminServiceOpts, SagaCallableConfig, SagaContextInterceptorOpts, SagaHandle, SagaInterceptorCall, SagaSignalBus, SagaStateRow, SagaStateStore, SagaStatus, StepOpts, TelemetryAuditEmitter, } from "./types";
3
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,QAAQ,SAAS,CAAC;AAC/B,eAAO,MAAM,OAAO,UAAU,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgBA,eAAO,MAAM,QAAQ,SAAS,CAAC;AAC/B,eAAO,MAAM,OAAO,UAAU,CAAC;AAE/B,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AACjE,YAAY,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,QAAQ,CAAC;AAEjE,OAAO,EACL,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,WAAW,GACZ,MAAM,QAAQ,CAAC;AAEhB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,cAAc,EACd,eAAe,EACf,aAAa,EACb,eAAe,EACf,WAAW,GACZ,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,wBAAwB,EACxB,+BAA+B,GAChC,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AACxD,YAAY,EACV,iBAAiB,EACjB,sBAAsB,EACtB,cAAc,EACd,gBAAgB,EAChB,8BAA8B,EAC9B,gBAAgB,EAChB,eAAe,EACf,eAAe,EACf,eAAe,GAChB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EACL,sBAAsB,EACtB,KAAK,0BAA0B,GAChC,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,cAAc,EACd,KAAK,kBAAkB,GACxB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAC3C,YAAY,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAGjE,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAE7E,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAE1C,OAAO,EACL,uBAAuB,EACvB,sBAAsB,EAEtB,SAAS,EACT,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,EACZ,mBAAmB,GACpB,MAAM,SAAS,CAAC;AAEjB,YAAY,EACV,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,aAAa,EACb,mBAAmB,EACnB,oBAAoB,EACpB,kBAAkB,EAClB,0BAA0B,EAC1B,UAAU,EACV,mBAAmB,EACnB,aAAa,EACb,YAAY,EACZ,cAAc,EACd,UAAU,EACV,QAAQ,EACR,qBAAqB,GACtB,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,37 @@
1
- // @nodii/saga — placeholder at v0.0.1.
2
- // Implementation lands at v0.1.0 per the locked feature_doc.
1
+ // @nodii/saga — v0.1.1 public barrel.
3
2
  //
4
- // Spec: https://planning.dev.nucleus-cloud.in/api/v1/feature-docs?serviceId=nodii-libs&docKey=saga
3
+ // Spec: planning hub feature_doc serviceId=nodii-libs docKey=saga.
4
+ // Polyglot ship: TS + Python + Go in parity.
5
+ //
6
+ // REAL implementations land at v0.1.1 for:
7
+ // - PostgresSagaStateStore (default when `db` is passed to initSaga)
8
+ // - RedisSignalBus (default when `redis` + `serviceId` are passed)
9
+ // - Pattern 2 step({ async: true }) via saga_outbox
10
+ // - SagaReaper (startSagaReaper)
11
+ // - applySagaMigrations + getSagaStateMigrationSQL (per-service runner)
12
+ // - completeAsyncStep / getCompletedAsyncOutput (Pattern 2 consumer side)
13
+ //
14
+ // Test doubles (InMemorySagaStateStore / NoopSignalBus / InMemorySignalBus
15
+ // / NoopIdempotency / InMemoryIdempotency) MOVED to `@nodii/saga/test-doubles`.
5
16
  export const LIB_NAME = "saga";
6
- export const VERSION = "0.0.1";
17
+ export const VERSION = "0.1.1";
18
+ export { initSaga, getSagaOrNull, _resetForTests } from "./init";
19
+ export { createSaga, withSaga, step, stepParallel, cancel, currentSaga, } from "./saga";
20
+ export { registerSaga, getRegisteredSaga, hasRegisteredSaga, _resetRegistryForTests, } from "./registry";
21
+ export { emitSagaSignal, awaitSagaSignal, awaitChildren, awaitAnySibling, getSiblings, } from "./signals";
22
+ export { sagaCallable, SAGA_CALLABLE_METADATA, readSagaCallableConfig, readSagaCallableRegistry, _resetDecoratorRegistryForTests, } from "./decorator";
23
+ export { sagaContext } from "./interceptor";
24
+ export { createSagaAdminServer } from "./admin-service";
25
+ // Production-bound state-store + signal-bus.
26
+ export { PostgresSagaStateStore, } from "./state-store/postgres";
27
+ export { RedisSignalBus, } from "./signal-bus/redis-stream";
28
+ // Pattern 2 async-step + reaper.
29
+ export { beginAsyncStep, completeAsyncStep, getCompletedAsyncOutput, SagaPausedSentinel, } from "./async-step";
30
+ export { startSagaReaper } from "./reaper";
31
+ // Migrations.
32
+ export { applySagaMigrations, getSagaStateMigrationSQL } from "./migrations";
33
+ export { uuidv7, isUuidv7 } from "./uuid";
34
+ export { MIN_JUSTIFICATION_CHARS, SAGA_ADMIN_PERMISSIONS,
35
+ // Typed-error hierarchy.
36
+ SagaError, SagaNotInitialized, SagaContextRequired, SagaStepFailed, SagaCompensationFailed, JustificationRequired, SagaTypeNotRegistered, NoActiveSaga, NotImplementedError, } from "./types";
7
37
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,uCAAuC;AACvC,6DAA6D;AAC7D,EAAE;AACF,mGAAmG;AAEnG,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC;AAC/B,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,EAAE;AACF,mEAAmE;AACnE,6CAA6C;AAC7C,EAAE;AACF,2CAA2C;AAC3C,uEAAuE;AACvE,+EAA+E;AAC/E,sDAAsD;AACtD,mCAAmC;AACnC,0EAA0E;AAC1E,4EAA4E;AAC5E,EAAE;AACF,2EAA2E;AAC3E,gFAAgF;AAEhF,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC;AAC/B,MAAM,CAAC,MAAM,OAAO,GAAG,OAAO,CAAC;AAE/B,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAGjE,OAAO,EACL,UAAU,EACV,QAAQ,EACR,IAAI,EACJ,YAAY,EACZ,MAAM,EACN,WAAW,GACZ,MAAM,QAAQ,CAAC;AAEhB,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,iBAAiB,EACjB,sBAAsB,GACvB,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,cAAc,EACd,eAAe,EACf,aAAa,EACb,eAAe,EACf,WAAW,GACZ,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,YAAY,EACZ,sBAAsB,EACtB,sBAAsB,EACtB,wBAAwB,EACxB,+BAA+B,GAChC,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAaxD,6CAA6C;AAC7C,OAAO,EACL,sBAAsB,GAEvB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,cAAc,GAEf,MAAM,2BAA2B,CAAC;AAEnC,iCAAiC;AACjC,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,GACnB,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAG3C,cAAc;AACd,OAAO,EAAE,mBAAmB,EAAE,wBAAwB,EAAE,MAAM,cAAc,CAAC;AAE7E,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAE1C,OAAO,EACL,uBAAuB,EACvB,sBAAsB;AACtB,yBAAyB;AACzB,SAAS,EACT,kBAAkB,EAClB,mBAAmB,EACnB,cAAc,EACd,sBAAsB,EACtB,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,EACZ,mBAAmB,GACpB,MAAM,SAAS,CAAC"}
package/dist/init.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { type IdempotencyShim, type SagaSignalBus, type SagaStateStore, type TelemetryAuditEmitter } from "./types";
2
+ type SqlClient = {
3
+ unsafe: (sql: string, args?: unknown[]) => Promise<any>;
4
+ };
5
+ type RedisLike = import("ioredis").Redis;
6
+ export interface InitSagaConfig {
7
+ /** Explicit state store override; if omitted, `db` must be provided. */
8
+ stateStore?: SagaStateStore;
9
+ /** Explicit signal bus override; if omitted, `redis` + `serviceId` are
10
+ * required to construct the default RedisSignalBus. */
11
+ signalBus?: SagaSignalBus;
12
+ /** Postgres client (postgres.js shape). Required unless `stateStore`. */
13
+ db?: SqlClient;
14
+ /** ioredis client. Required unless `signalBus`. */
15
+ redis?: RedisLike;
16
+ /** Used to namespace the Redis stream + idempotency keys. Required
17
+ * unless `signalBus` is provided. */
18
+ serviceId?: string;
19
+ /** Idempotency shim; defaults to in-process InMemoryIdempotency until
20
+ * @nodii/idempotency v0.1.0 publishes. */
21
+ idempotency?: IdempotencyShim;
22
+ /** D33 audit emitter; optional. */
23
+ telemetryAudit?: TelemetryAuditEmitter;
24
+ /** Logger used by the sagaContext interceptor + async-step warn path. */
25
+ logger?: {
26
+ warn(msg: string, fields?: Record<string, unknown>): void;
27
+ };
28
+ }
29
+ export interface ResolvedSagaConfig {
30
+ stateStore: SagaStateStore;
31
+ signalBus: SagaSignalBus;
32
+ idempotency: IdempotencyShim;
33
+ telemetryAudit: TelemetryAuditEmitter | null;
34
+ logger: {
35
+ warn(msg: string, fields?: Record<string, unknown>): void;
36
+ };
37
+ serviceId: string | null;
38
+ }
39
+ export declare function initSaga(config?: InitSagaConfig): void;
40
+ export declare function requireSaga(): ResolvedSagaConfig;
41
+ export declare function getSagaOrNull(): ResolvedSagaConfig | null;
42
+ /** Test-only reset; NOT exported through the public barrel. */
43
+ export declare function _resetForTests(): void;
44
+ export {};
45
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAgBA,OAAO,EACL,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAE3B,MAAM,SAAS,CAAC;AAEjB,KAAK,SAAS,GAAG;IAEf,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;CACzD,CAAC;AAEF,KAAK,SAAS,GAAG,OAAO,SAAS,EAAE,KAAK,CAAC;AAEzC,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B;4DACwD;IACxD,SAAS,CAAC,EAAE,aAAa,CAAC;IAC1B,yEAAyE;IACzE,EAAE,CAAC,EAAE,SAAS,CAAC;IACf,mDAAmD;IACnD,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB;0CACsC;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;+CAC2C;IAC3C,WAAW,CAAC,EAAE,eAAe,CAAC;IAC9B,mCAAmC;IACnC,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,yEAAyE;IACzE,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC;CACxE;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,cAAc,CAAC;IAC3B,SAAS,EAAE,aAAa,CAAC;IACzB,WAAW,EAAE,eAAe,CAAC;IAC7B,cAAc,EAAE,qBAAqB,GAAG,IAAI,CAAC;IAC7C,MAAM,EAAE;QAAE,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;KAAE,CAAC;IACtE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAID,wBAAgB,QAAQ,CAAC,MAAM,GAAE,cAAmB,GAAG,IAAI,CA0D1D;AAED,wBAAgB,WAAW,IAAI,kBAAkB,CAKhD;AAED,wBAAgB,aAAa,IAAI,kBAAkB,GAAG,IAAI,CAEzD;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,IAAI,IAAI,CAErC"}
package/dist/init.js ADDED
@@ -0,0 +1,86 @@
1
+ // initSaga — process-local bootstrap.
2
+ //
3
+ // Spec § 5.15. Real production posture (v0.1.1+):
4
+ // - Default state store: `PostgresSagaStateStore` (requires `db`).
5
+ // - Default signal bus: `RedisSignalBus` (requires `redis` + `serviceId`).
6
+ // - Default idempotency: `InMemoryIdempotency` (until @nodii/idempotency
7
+ // lands — this remains a temporary shim that exercises the contract).
8
+ //
9
+ // Test doubles (`InMemorySagaStateStore`, `NoopSignalBus`,
10
+ // `InMemorySignalBus`) live under `./test-doubles` and must be wired
11
+ // explicitly — they are NOT defaults.
12
+ import { InMemoryIdempotency } from "./idempotency";
13
+ import { PostgresSagaStateStore } from "./state-store/postgres";
14
+ import { RedisSignalBus } from "./signal-bus/redis-stream";
15
+ import { NoopSignalBus } from "./test-doubles/in-memory-signal-bus";
16
+ import { SagaNotInitialized, } from "./types";
17
+ let resolved = null;
18
+ export function initSaga(config = {}) {
19
+ if (resolved) {
20
+ // eslint-disable-next-line no-console
21
+ console.warn("[@nodii/saga] initSaga() called more than once; ignoring subsequent call.");
22
+ return;
23
+ }
24
+ // Resolve state store.
25
+ let stateStore;
26
+ if (config.stateStore) {
27
+ stateStore = config.stateStore;
28
+ }
29
+ else if (config.db) {
30
+ stateStore = new PostgresSagaStateStore({ sql: config.db });
31
+ }
32
+ else {
33
+ throw new Error("@nodii/saga: initSaga requires either { stateStore } or { db }. " +
34
+ "For unit tests, import `InMemorySagaStateStore` from '@nodii/saga/test-doubles' and pass it explicitly.");
35
+ }
36
+ // Resolve signal bus.
37
+ let signalBus;
38
+ if (config.signalBus) {
39
+ signalBus = config.signalBus;
40
+ }
41
+ else if (config.redis && config.serviceId) {
42
+ signalBus = new RedisSignalBus({
43
+ redis: config.redis,
44
+ serviceId: config.serviceId,
45
+ });
46
+ }
47
+ else if (config.stateStore && !config.db) {
48
+ // Test-only ergonomics — when a caller passes an explicit stateStore
49
+ // (clearly opting out of the Postgres default) we tolerate an
50
+ // implicit NoopSignalBus so unit tests that don't exercise signals
51
+ // don't need to wire one. Production wiring (`db` set) MUST supply a
52
+ // real signal bus.
53
+ signalBus = new NoopSignalBus();
54
+ }
55
+ else {
56
+ throw new Error("@nodii/saga: initSaga requires either { signalBus } or { redis, serviceId }. " +
57
+ "For unit tests, import `NoopSignalBus`/`InMemorySignalBus` from '@nodii/saga/test-doubles' and pass it explicitly.");
58
+ }
59
+ resolved = {
60
+ stateStore,
61
+ signalBus,
62
+ idempotency: config.idempotency ?? new InMemoryIdempotency(),
63
+ telemetryAudit: config.telemetryAudit ?? null,
64
+ serviceId: config.serviceId ?? null,
65
+ logger: config.logger ?? {
66
+ warn(msg, fields) {
67
+ // eslint-disable-next-line no-console
68
+ console.warn(`[@nodii/saga] ${msg}`, fields ?? {});
69
+ },
70
+ },
71
+ };
72
+ }
73
+ export function requireSaga() {
74
+ if (!resolved) {
75
+ throw new SagaNotInitialized();
76
+ }
77
+ return resolved;
78
+ }
79
+ export function getSagaOrNull() {
80
+ return resolved;
81
+ }
82
+ /** Test-only reset; NOT exported through the public barrel. */
83
+ export function _resetForTests() {
84
+ resolved = null;
85
+ }
86
+ //# sourceMappingURL=init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.js","sourceRoot":"","sources":["../src/init.ts"],"names":[],"mappings":"AAAA,sCAAsC;AACtC,EAAE;AACF,kDAAkD;AAClD,qEAAqE;AACrE,6EAA6E;AAC7E,2EAA2E;AAC3E,0EAA0E;AAC1E,EAAE;AACF,2DAA2D;AAC3D,qEAAqE;AACrE,sCAAsC;AAEtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAChE,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,qCAAqC,CAAC;AACpE,OAAO,EAKL,kBAAkB,GACnB,MAAM,SAAS,CAAC;AAwCjB,IAAI,QAAQ,GAA8B,IAAI,CAAC;AAE/C,MAAM,UAAU,QAAQ,CAAC,SAAyB,EAAE;IAClD,IAAI,QAAQ,EAAE,CAAC;QACb,sCAAsC;QACtC,OAAO,CAAC,IAAI,CACV,2EAA2E,CAC5E,CAAC;QACF,OAAO;IACT,CAAC;IAED,uBAAuB;IACvB,IAAI,UAA0B,CAAC;IAC/B,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;QACtB,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;IACjC,CAAC;SAAM,IAAI,MAAM,CAAC,EAAE,EAAE,CAAC;QACrB,UAAU,GAAG,IAAI,sBAAsB,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;IAC9D,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,KAAK,CACb,kEAAkE;YAChE,yGAAyG,CAC5G,CAAC;IACJ,CAAC;IAED,sBAAsB;IACtB,IAAI,SAAwB,CAAC;IAC7B,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;QACrB,SAAS,GAAG,MAAM,CAAC,SAAS,CAAC;IAC/B,CAAC;SAAM,IAAI,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;QAC5C,SAAS,GAAG,IAAI,cAAc,CAAC;YAC7B,KAAK,EAAE,MAAM,CAAC,KAAK;YACnB,SAAS,EAAE,MAAM,CAAC,SAAS;SAC5B,CAAC,CAAC;IACL,CAAC;SAAM,IAAI,MAAM,CAAC,UAAU,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC;QAC3C,qEAAqE;QACrE,8DAA8D;QAC9D,mEAAmE;QACnE,qEAAqE;QACrE,mBAAmB;QACnB,SAAS,GAAG,IAAI,aAAa,EAAE,CAAC;IAClC,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,KAAK,CACb,+EAA+E;YAC7E,oHAAoH,CACvH,CAAC;IACJ,CAAC;IAED,QAAQ,GAAG;QACT,UAAU;QACV,SAAS;QACT,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,IAAI,mBAAmB,EAAE;QAC5D,cAAc,EAAE,MAAM,CAAC,cAAc,IAAI,IAAI;QAC7C,SAAS,EAAE,MAAM,CAAC,SAAS,IAAI,IAAI;QACnC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI;YACvB,IAAI,CAAC,GAAG,EAAE,MAAM;gBACd,sCAAsC;gBACtC,OAAO,CAAC,IAAI,CAAC,iBAAiB,GAAG,EAAE,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC;YACrD,CAAC;SACF;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,kBAAkB,EAAE,CAAC;IACjC,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,cAAc;IAC5B,QAAQ,GAAG,IAAI,CAAC;AAClB,CAAC"}
@@ -0,0 +1,5 @@
1
+ import { type SagaContextInterceptorOpts, type SagaInterceptorCall } from "./types";
2
+ type Handler = (call: SagaInterceptorCall) => Promise<unknown>;
3
+ export declare function sagaContext(opts: SagaContextInterceptorOpts): (methodName: string, next: Handler) => Handler;
4
+ export {};
5
+ //# sourceMappingURL=interceptor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interceptor.d.ts","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,KAAK,0BAA0B,EAC/B,KAAK,mBAAmB,EAEzB,MAAM,SAAS,CAAC;AAEjB,KAAK,OAAO,GAAG,CAAC,IAAI,EAAE,mBAAmB,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAE/D,wBAAgB,WAAW,CAAC,IAAI,EAAE,0BAA0B,IAExD,YAAY,MAAM,EAClB,MAAM,OAAO,KACZ,OAAO,CAwBX"}
@@ -0,0 +1,34 @@
1
+ // sagaContext interceptor — § 5.7 + comm doctrine § 13.3.
2
+ //
3
+ // Server-side. Composed inside the auth wrapper:
4
+ // withLoggingUnary > withAuditUnary > withAuthUnary > sagaContext > handler
5
+ //
6
+ // Reads x-nodii-saga-id; if absent and the method is tagged
7
+ // `require_saga_context: true`, emits a D33 `saga_context_required_violation`
8
+ // audit row and either warns (default) or rejects depending on `enforce`.
9
+ import { requireSaga } from "./init";
10
+ import { SagaContextRequired, } from "./types";
11
+ export function sagaContext(opts) {
12
+ return function interceptorFactory(methodName, next) {
13
+ return async (call) => {
14
+ const sagaIdValues = call.metadata.get("x-nodii-saga-id");
15
+ const sagaId = sagaIdValues[0] ?? "";
16
+ if (!sagaId && opts.requireSagaContextMethods.has(methodName)) {
17
+ const cfg = requireSaga();
18
+ await cfg.telemetryAudit?.emit({
19
+ action: "saga_context_required_violation",
20
+ target_kind: "grpc_method",
21
+ target_id: methodName,
22
+ payload: { method: methodName },
23
+ });
24
+ const enforce = opts.enforce ?? "warn";
25
+ if (enforce === "reject") {
26
+ throw new SagaContextRequired(methodName);
27
+ }
28
+ (opts.logger ?? cfg.logger).warn(`saga_context_required: ${methodName} called without x-nodii-saga-id (warn mode)`, { method: methodName });
29
+ }
30
+ return next(call);
31
+ };
32
+ };
33
+ }
34
+ //# sourceMappingURL=interceptor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"interceptor.js","sourceRoot":"","sources":["../src/interceptor.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,EAAE;AACF,iDAAiD;AACjD,8EAA8E;AAC9E,EAAE;AACF,4DAA4D;AAC5D,8EAA8E;AAC9E,0EAA0E;AAE1E,OAAO,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AACrC,OAAO,EAGL,mBAAmB,GACpB,MAAM,SAAS,CAAC;AAIjB,MAAM,UAAU,WAAW,CAAC,IAAgC;IAC1D,OAAO,SAAS,kBAAkB,CAChC,UAAkB,EAClB,IAAa;QAEb,OAAO,KAAK,EAAE,IAAyB,EAAoB,EAAE;YAC3D,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;YAC1D,MAAM,MAAM,GAAI,YAAY,CAAC,CAAC,CAAwB,IAAI,EAAE,CAAC;YAC7D,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC,yBAAyB,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC;gBAC9D,MAAM,GAAG,GAAG,WAAW,EAAE,CAAC;gBAC1B,MAAM,GAAG,CAAC,cAAc,EAAE,IAAI,CAAC;oBAC7B,MAAM,EAAE,iCAAiC;oBACzC,WAAW,EAAE,aAAa;oBAC1B,SAAS,EAAE,UAAU;oBACrB,OAAO,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE;iBAChC,CAAC,CAAC;gBACH,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,MAAM,CAAC;gBACvC,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;oBACzB,MAAM,IAAI,mBAAmB,CAAC,UAAU,CAAC,CAAC;gBAC5C,CAAC;gBACD,CAAC,IAAI,CAAC,MAAM,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAC9B,0BAA0B,UAAU,6CAA6C,EACjF,EAAE,MAAM,EAAE,UAAU,EAAE,CACvB,CAAC;YACJ,CAAC;YACD,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC;QACpB,CAAC,CAAC;IACJ,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,19 @@
1
+ type SqlClient = {
2
+ unsafe: (sql: string, args?: unknown[]) => Promise<any>;
3
+ };
4
+ export declare function getSagaStateMigrationSQL(): string;
5
+ /**
6
+ * Apply the saga_state + saga_outbox migrations idempotently. Safe to call
7
+ * at every service boot — the DDL uses `IF NOT EXISTS` throughout.
8
+ *
9
+ * postgres.js refuses to send multi-statement queries through the
10
+ * extended-query protocol — even with no args — so we split on the
11
+ * literal semicolon-followed-by-newline boundaries we control in the
12
+ * shipped `.sql` file. Comments (lines starting `--`) and pure
13
+ * whitespace blocks are skipped.
14
+ */
15
+ export declare function applySagaMigrations(sql: SqlClient): Promise<void>;
16
+ /** Internal — exported only for unit-test coverage on the splitter. */
17
+ export declare function splitStatements(ddl: string): string[];
18
+ export {};
19
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAOA,KAAK,SAAS,GAAG;IAEf,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;CACzD,CAAC;AA8DF,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED;;;;;;;;;GASG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAMvE;AAED,uEAAuE;AACvE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAerD"}
@@ -0,0 +1,106 @@
1
+ // Migration helper — applies the saga_state + saga_outbox DDL idempotently.
2
+ //
3
+ // Services consume this via `applySagaMigrations(sql)` at boot or via the
4
+ // per-service migration runner. The SQL is shipped alongside this module
5
+ // as `001-saga-state.sql` so a service that uses a different runner
6
+ // (Flyway, golang-migrate, alembic, etc.) can copy it directly.
7
+ /**
8
+ * Canonical saga_state + saga_outbox migration. Kept inline here (NOT
9
+ * read from disk at runtime) so the published artifact + the synthetic-
10
+ * consumer's `file:` install both work without a side-channel for the
11
+ * .sql file. The source-of-truth copy lives at
12
+ * `src/migrations/001-saga-state.sql` for downstream services that
13
+ * prefer running it via Flyway / sqlx-cli / golang-migrate / alembic.
14
+ */
15
+ const SAGA_STATE_MIGRATION_SQL = `
16
+ CREATE TABLE IF NOT EXISTS saga_state (
17
+ id TEXT PRIMARY KEY,
18
+ tenant_id TEXT,
19
+ type TEXT NOT NULL,
20
+ status TEXT NOT NULL,
21
+ current_step TEXT,
22
+ step_outputs JSONB NOT NULL DEFAULT '{}'::jsonb,
23
+ episode INT NOT NULL DEFAULT 1,
24
+ trigger_trace_id TEXT NOT NULL,
25
+ trigger_span_id TEXT NOT NULL,
26
+ trigger_request_id TEXT NOT NULL,
27
+ parent_saga_id TEXT,
28
+ parent_relationship TEXT NOT NULL DEFAULT 'child',
29
+ children JSONB NOT NULL DEFAULT '[]'::jsonb,
30
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31
+ completed_at TIMESTAMPTZ,
32
+ next_resume_at TIMESTAMPTZ,
33
+ failure_reason TEXT,
34
+ failure_step TEXT,
35
+ compensation_log JSONB NOT NULL DEFAULT '[]'::jsonb,
36
+ last_admin_action TEXT,
37
+ last_admin_actor_id TEXT,
38
+ last_admin_at TIMESTAMPTZ,
39
+ input JSONB NOT NULL,
40
+ cancel_reason TEXT
41
+ );
42
+ CREATE UNIQUE INDEX IF NOT EXISTS saga_state_trigger_trace_id_uniq
43
+ ON saga_state (trigger_trace_id);
44
+ CREATE INDEX IF NOT EXISTS saga_state_dead_letter_idx
45
+ ON saga_state (tenant_id, status, started_at);
46
+ CREATE INDEX IF NOT EXISTS saga_state_reaper_idx
47
+ ON saga_state (next_resume_at)
48
+ WHERE next_resume_at IS NOT NULL;
49
+ CREATE INDEX IF NOT EXISTS saga_state_parent_idx
50
+ ON saga_state (parent_saga_id)
51
+ WHERE parent_saga_id IS NOT NULL;
52
+ CREATE TABLE IF NOT EXISTS saga_outbox (
53
+ id UUID PRIMARY KEY,
54
+ saga_id TEXT NOT NULL,
55
+ step_name TEXT NOT NULL,
56
+ event_type TEXT NOT NULL,
57
+ payload JSONB NOT NULL,
58
+ tenant_id TEXT,
59
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
60
+ delivered_at TIMESTAMPTZ
61
+ );
62
+ CREATE INDEX IF NOT EXISTS saga_outbox_saga_idx ON saga_outbox (saga_id, created_at);
63
+ CREATE INDEX IF NOT EXISTS saga_outbox_undelivered_idx ON saga_outbox (delivered_at)
64
+ WHERE delivered_at IS NULL;
65
+ `.trim();
66
+ export function getSagaStateMigrationSQL() {
67
+ return SAGA_STATE_MIGRATION_SQL;
68
+ }
69
+ /**
70
+ * Apply the saga_state + saga_outbox migrations idempotently. Safe to call
71
+ * at every service boot — the DDL uses `IF NOT EXISTS` throughout.
72
+ *
73
+ * postgres.js refuses to send multi-statement queries through the
74
+ * extended-query protocol — even with no args — so we split on the
75
+ * literal semicolon-followed-by-newline boundaries we control in the
76
+ * shipped `.sql` file. Comments (lines starting `--`) and pure
77
+ * whitespace blocks are skipped.
78
+ */
79
+ export async function applySagaMigrations(sql) {
80
+ const ddl = getSagaStateMigrationSQL();
81
+ const statements = splitStatements(ddl);
82
+ for (const stmt of statements) {
83
+ await sql.unsafe(stmt);
84
+ }
85
+ }
86
+ /** Internal — exported only for unit-test coverage on the splitter. */
87
+ export function splitStatements(ddl) {
88
+ const out = [];
89
+ let buf = "";
90
+ for (const rawLine of ddl.split("\n")) {
91
+ const line = rawLine.trim();
92
+ if (!line || line.startsWith("--"))
93
+ continue;
94
+ buf = buf ? `${buf}\n${rawLine}` : rawLine;
95
+ if (line.endsWith(";")) {
96
+ const cleaned = buf.trim();
97
+ if (cleaned)
98
+ out.push(cleaned);
99
+ buf = "";
100
+ }
101
+ }
102
+ if (buf.trim())
103
+ out.push(buf.trim());
104
+ return out;
105
+ }
106
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,0EAA0E;AAC1E,yEAAyE;AACzE,oEAAoE;AACpE,gEAAgE;AAOhE;;;;;;;GAOG;AACH,MAAM,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkDhC,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,UAAU,wBAAwB;IACtC,OAAO,wBAAwB,CAAC;AAClC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,GAAc;IACtD,MAAM,GAAG,GAAG,wBAAwB,EAAE,CAAC;IACvC,MAAM,UAAU,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC;IACxC,KAAK,MAAM,IAAI,IAAI,UAAU,EAAE,CAAC;QAC9B,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;AACH,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,eAAe,CAAC,GAAW;IACzC,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAS;QAC7C,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,KAAK,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;QAC3C,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACvB,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;YAC3B,IAAI,OAAO;gBAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC/B,GAAG,GAAG,EAAE,CAAC;QACX,CAAC;IACH,CAAC;IACD,IAAI,GAAG,CAAC,IAAI,EAAE;QAAE,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;IACrC,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,27 @@
1
+ import type { SagaStateRow } from "./types";
2
+ export interface SagaReaperOpts {
3
+ /** How often to scan; spec default = 5 * 60_000 ms (5 min). */
4
+ intervalMs?: number;
5
+ /** Grace period past `next_resume_at` before finalizing. Spec default
6
+ * = 24 * 60 * 60_000 (24h). For tests, set to a small value. */
7
+ gracePeriodMs?: number;
8
+ /** Test hook — replaces setInterval/clearInterval for deterministic
9
+ * per-iteration control. */
10
+ scheduler?: {
11
+ setInterval(handler: () => void, ms: number): unknown;
12
+ clearInterval(token: unknown): void;
13
+ };
14
+ /** Test hook — fires on every transition so tests can assert. */
15
+ onTransition?: (saga: SagaStateRow) => void;
16
+ }
17
+ export interface SagaReaperHandle {
18
+ /** Run one iteration synchronously (for tests + manual triggers). */
19
+ runOnce(): Promise<{
20
+ scanned: number;
21
+ reaped: number;
22
+ }>;
23
+ /** Stop the periodic loop. */
24
+ stop(): void;
25
+ }
26
+ export declare function startSagaReaper(opts?: SagaReaperOpts): SagaReaperHandle;
27
+ //# sourceMappingURL=reaper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reaper.d.ts","sourceRoot":"","sources":["../src/reaper.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C,MAAM,WAAW,cAAc;IAC7B,+DAA+D;IAC/D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;qEACiE;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;iCAC6B;IAC7B,SAAS,CAAC,EAAE;QACV,WAAW,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;QACtD,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;KACrC,CAAC;IACF,iEAAiE;IACjE,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,gBAAgB;IAC/B,qEAAqE;IACrE,OAAO,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,8BAA8B;IAC9B,IAAI,IAAI,IAAI,CAAC;CACd;AAED,wBAAgB,eAAe,CAAC,IAAI,GAAE,cAAmB,GAAG,gBAAgB,CAuE3E"}
package/dist/reaper.js ADDED
@@ -0,0 +1,79 @@
1
+ // Saga reaper — § 5.8 + comm doctrine § 4.2.
2
+ //
3
+ // A per-service scheduled task that scans for `status='paused' AND
4
+ // next_resume_at < now() - grace` and finalizes to `technical_failure`
5
+ // with `failure_reason='resume_signal_timeout'`. M17 alerting metric
6
+ // fires once per transition.
7
+ //
8
+ // `startSagaReaper` returns a `stop()` handle. Tests use a tight
9
+ // `intervalMs` (e.g. 50) so the loop runs deterministically; production
10
+ // uses the spec default of 5 min.
11
+ import { requireSaga } from "./init";
12
+ export function startSagaReaper(opts = {}) {
13
+ const intervalMs = opts.intervalMs ?? 5 * 60_000;
14
+ const gracePeriodMs = opts.gracePeriodMs ?? 24 * 60 * 60_000;
15
+ const scheduler = opts.scheduler ??
16
+ {
17
+ setInterval(h, ms) {
18
+ return setInterval(h, ms);
19
+ },
20
+ clearInterval(token) {
21
+ clearInterval(token);
22
+ },
23
+ };
24
+ let running = false;
25
+ const runOnce = async () => {
26
+ if (running)
27
+ return { scanned: 0, reaped: 0 };
28
+ running = true;
29
+ try {
30
+ const cfg = requireSaga();
31
+ if (!cfg.stateStore.listStalePaused) {
32
+ cfg.logger.warn("saga reaper: configured state store does not implement listStalePaused; reaper is a no-op", {});
33
+ return { scanned: 0, reaped: 0 };
34
+ }
35
+ const cutoffIso = new Date(Date.now() - gracePeriodMs).toISOString();
36
+ const stale = await cfg.stateStore.listStalePaused(cutoffIso);
37
+ let reaped = 0;
38
+ for (const row of stale) {
39
+ try {
40
+ await cfg.stateStore.updateSagaStatus(row.id, {
41
+ status: "technical_failure",
42
+ failure_reason: "resume_signal_timeout",
43
+ completed_at: new Date().toISOString(),
44
+ });
45
+ if (cfg.telemetryAudit) {
46
+ await cfg.telemetryAudit.emit({
47
+ action: "saga_reaper_grace_expired",
48
+ target_kind: "saga",
49
+ target_id: row.id,
50
+ payload: { type: row.type, grace_ms: gracePeriodMs },
51
+ });
52
+ }
53
+ opts.onTransition?.(row);
54
+ reaped += 1;
55
+ }
56
+ catch (err) {
57
+ cfg.logger.warn("saga reaper: failed to finalize paused saga", {
58
+ saga_id: row.id,
59
+ error: err instanceof Error ? err.message : String(err),
60
+ });
61
+ }
62
+ }
63
+ return { scanned: stale.length, reaped };
64
+ }
65
+ finally {
66
+ running = false;
67
+ }
68
+ };
69
+ const token = scheduler.setInterval(() => {
70
+ void runOnce();
71
+ }, intervalMs);
72
+ return {
73
+ runOnce,
74
+ stop() {
75
+ scheduler.clearInterval(token);
76
+ },
77
+ };
78
+ }
79
+ //# sourceMappingURL=reaper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reaper.js","sourceRoot":"","sources":["../src/reaper.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ type SagaFn = (input: unknown) => Promise<unknown>;
2
+ export declare function registerSaga<I, O>(type: string, fn: (input: I) => Promise<O>): void;
3
+ export declare function getRegisteredSaga(type: string): SagaFn;
4
+ export declare function hasRegisteredSaga(type: string): boolean;
5
+ export declare function _resetRegistryForTests(): void;
6
+ export {};
7
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAQA,KAAK,MAAM,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAInD,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,EAC/B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAC3B,IAAI,CAEN;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAItD;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C"}
@@ -0,0 +1,23 @@
1
+ // registerSaga — process-local registry per § 5.8 (D167).
2
+ //
3
+ // Used by the resume primitive (registry lookup keyed by saga.type) and by
4
+ // adversarial test paths that need to look up a missing entry to assert
5
+ // SagaTypeNotRegistered.
6
+ import { SagaTypeNotRegistered } from "./types";
7
+ const registry = new Map();
8
+ export function registerSaga(type, fn) {
9
+ registry.set(type, fn);
10
+ }
11
+ export function getRegisteredSaga(type) {
12
+ const fn = registry.get(type);
13
+ if (!fn)
14
+ throw new SagaTypeNotRegistered(type);
15
+ return fn;
16
+ }
17
+ export function hasRegisteredSaga(type) {
18
+ return registry.has(type);
19
+ }
20
+ export function _resetRegistryForTests() {
21
+ registry.clear();
22
+ }
23
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,EAAE;AACF,2EAA2E;AAC3E,wEAAwE;AACxE,yBAAyB;AAEzB,OAAO,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAIhD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;AAE3C,MAAM,UAAU,YAAY,CAC1B,IAAY,EACZ,EAA4B;IAE5B,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,EAAY,CAAC,CAAC;AACnC,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,MAAM,EAAE,GAAG,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAC9B,IAAI,CAAC,EAAE;QAAE,MAAM,IAAI,qBAAqB,CAAC,IAAI,CAAC,CAAC;IAC/C,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AAC5B,CAAC;AAED,MAAM,UAAU,sBAAsB;IACpC,QAAQ,CAAC,KAAK,EAAE,CAAC;AACnB,CAAC"}