@requence/event-sourcing 1.4.1 → 1.5.1

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 (133) hide show
  1. package/README.md +6 -5
  2. package/dist/{chunk-24xjphpk.js → chunk-06anxtk6.js} +6 -6
  3. package/dist/{chunk-24xjphpk.js.map → chunk-06anxtk6.js.map} +2 -2
  4. package/dist/{chunk-263c07sn.js → chunk-1mr4cwpw.js} +8 -8
  5. package/dist/{chunk-263c07sn.js.map → chunk-1mr4cwpw.js.map} +2 -2
  6. package/dist/{chunk-3ejt4h7x.js → chunk-2wqpktzp.js} +78 -66
  7. package/dist/chunk-2wqpktzp.js.map +10 -0
  8. package/dist/{chunk-dgzw1nj9.js → chunk-467qtkth.js} +8 -3
  9. package/dist/chunk-467qtkth.js.map +10 -0
  10. package/dist/chunk-6dn6wxg7.js +11 -0
  11. package/dist/{chunk-jd4q8fcf.js.map → chunk-6dn6wxg7.js.map} +2 -2
  12. package/dist/{chunk-pc1q4se9.js → chunk-9qtjz725.js} +2 -2
  13. package/dist/{chunk-pc1q4se9.js.map → chunk-9qtjz725.js.map} +2 -2
  14. package/dist/{chunk-tfy01fah.js → chunk-c7sytwpp.js} +4 -4
  15. package/dist/{chunk-tfy01fah.js.map → chunk-c7sytwpp.js.map} +2 -2
  16. package/dist/{chunk-vvv14qxy.js → chunk-d6jqbrq2.js} +33 -31
  17. package/dist/{chunk-vvv14qxy.js.map → chunk-d6jqbrq2.js.map} +3 -3
  18. package/dist/{chunk-vep2byx4.js → chunk-f95c3da4.js} +77 -64
  19. package/dist/chunk-f95c3da4.js.map +10 -0
  20. package/dist/chunk-fpjnk143.js +13 -0
  21. package/dist/{chunk-6q8mr1sg.js.map → chunk-fpjnk143.js.map} +2 -2
  22. package/dist/{chunk-8qn1nqnh.js → chunk-fxb9fbvz.js} +9 -9
  23. package/dist/{chunk-8qn1nqnh.js.map → chunk-fxb9fbvz.js.map} +2 -2
  24. package/dist/chunk-hmvwn02w.js +22 -0
  25. package/dist/{chunk-6yrb30rx.js.map → chunk-hmvwn02w.js.map} +2 -2
  26. package/dist/{chunk-ty9bhdvx.js → chunk-j4mwjx07.js} +5 -3
  27. package/dist/chunk-j4mwjx07.js.map +10 -0
  28. package/dist/{chunk-391m4mfp.js → chunk-jw9e0fr0.js} +13 -13
  29. package/dist/{chunk-391m4mfp.js.map → chunk-jw9e0fr0.js.map} +2 -2
  30. package/dist/chunk-mbxxvskx.js +67 -0
  31. package/dist/chunk-mbxxvskx.js.map +10 -0
  32. package/dist/{chunk-yn2sjcg3.js → chunk-nrhw3kde.js} +57 -8
  33. package/dist/chunk-nrhw3kde.js.map +10 -0
  34. package/dist/{chunk-57b88rt5.js → chunk-pcggjrxr.js} +32 -32
  35. package/dist/{chunk-57b88rt5.js.map → chunk-pcggjrxr.js.map} +2 -2
  36. package/dist/chunk-tkwe4r0y.js +22 -0
  37. package/dist/{chunk-2fe8jjdc.js.map → chunk-tkwe4r0y.js.map} +2 -2
  38. package/dist/{chunk-z388cd25.js → chunk-zkz6ew8c.js} +5 -5
  39. package/dist/{chunk-z388cd25.js.map → chunk-zkz6ew8c.js.map} +2 -2
  40. package/dist/createAggregateRoot.d.ts +1 -1
  41. package/dist/createAggregateRoot.d.ts.map +1 -1
  42. package/dist/createAggregateRoot.js +15 -14
  43. package/dist/createAggregateRoot.js.map +1 -1
  44. package/dist/createCheckpointApi.d.ts +30 -0
  45. package/dist/createCheckpointApi.d.ts.map +1 -1
  46. package/dist/createCheckpointApi.js +5 -5
  47. package/dist/createCheckpointApi.js.map +1 -1
  48. package/dist/createEventListener.js +8 -8
  49. package/dist/createEventListener.js.map +1 -1
  50. package/dist/createEventStore.js +16 -16
  51. package/dist/createEventStore.js.map +1 -1
  52. package/dist/createProcessManager.d.ts.map +1 -1
  53. package/dist/createProcessManager.js +12 -12
  54. package/dist/createProcessManager.js.map +1 -1
  55. package/dist/createProjection.d.ts.map +1 -1
  56. package/dist/createProjection.js +12 -12
  57. package/dist/createProjection.js.map +1 -1
  58. package/dist/drizzle/index.js +132 -132
  59. package/dist/drizzle/index.js.map +2 -2
  60. package/dist/drizzle/postgres.js +14 -14
  61. package/dist/drizzle/postgres.js.map +1 -1
  62. package/dist/errors.js +24 -24
  63. package/dist/errors.js.map +1 -1
  64. package/dist/index.js +63 -63
  65. package/dist/index.js.map +1 -1
  66. package/dist/lock.d.ts +11 -3
  67. package/dist/lock.d.ts.map +1 -1
  68. package/dist/lock.js +4 -2
  69. package/dist/lock.js.map +1 -1
  70. package/dist/memory/index.d.ts +14 -3
  71. package/dist/memory/index.d.ts.map +1 -1
  72. package/dist/memory/index.js +52 -45
  73. package/dist/memory/index.js.map +3 -3
  74. package/dist/memory/index.spec.d.ts +2 -0
  75. package/dist/memory/index.spec.d.ts.map +1 -0
  76. package/dist/redis/index.js +5 -4
  77. package/dist/redis/index.js.map +1 -1
  78. package/dist/redis/redisLock.d.ts +1 -1
  79. package/dist/redis/redisLock.d.ts.map +1 -1
  80. package/dist/redis/redisLock.js +5 -4
  81. package/dist/redis/redisLock.js.map +1 -1
  82. package/dist/refresh.js +9 -9
  83. package/dist/refresh.js.map +1 -1
  84. package/dist/replay.js +9 -9
  85. package/dist/replay.js.map +1 -1
  86. package/dist/snapshotGenerator.js +2 -2
  87. package/dist/superjson.js +8 -8
  88. package/dist/superjson.js.map +1 -1
  89. package/dist/surreal/index.d.ts +35 -0
  90. package/dist/surreal/index.d.ts.map +1 -0
  91. package/dist/surreal/index.js +415 -0
  92. package/dist/surreal/index.js.map +10 -0
  93. package/dist/surreal/index.spec.d.ts +2 -0
  94. package/dist/surreal/index.spec.d.ts.map +1 -0
  95. package/dist/surreal/schema.d.ts +12 -0
  96. package/dist/surreal/schema.d.ts.map +1 -0
  97. package/dist/surreal/schema.js +12 -0
  98. package/dist/surreal/schema.js.map +9 -0
  99. package/dist/tests/projectionMultiInstance.spec.d.ts +2 -0
  100. package/dist/tests/projectionMultiInstance.spec.d.ts.map +1 -0
  101. package/dist/tests/storageDriverContract.d.ts +19 -0
  102. package/dist/tests/storageDriverContract.d.ts.map +1 -0
  103. package/dist/transaction.js +1 -1
  104. package/dist/utils.js +6 -6
  105. package/dist/utils.js.map +1 -1
  106. package/dist/wrappedEventBuilder.js +8 -8
  107. package/dist/wrappedEventBuilder.js.map +1 -1
  108. package/package.json +19 -10
  109. package/src/createAggregateRoot.ts +24 -5
  110. package/src/createCheckpointApi.ts +98 -0
  111. package/src/createProcessManager.ts +7 -6
  112. package/src/createProjection.ts +91 -65
  113. package/src/lock.ts +17 -3
  114. package/src/memory/index.spec.ts +4 -0
  115. package/src/memory/index.ts +55 -29
  116. package/src/redis/redisLock.ts +6 -2
  117. package/src/surreal/index.spec.ts +152 -0
  118. package/src/surreal/index.ts +604 -0
  119. package/src/surreal/schema.ts +68 -0
  120. package/src/tests/aggregateRoot.spec.ts +48 -3
  121. package/src/tests/eventStore.spec.ts +1 -3
  122. package/src/tests/processManagerMultiInstance.spec.ts +18 -7
  123. package/src/tests/projectionMultiInstance.spec.ts +283 -0
  124. package/src/tests/storageDriverContract.ts +612 -0
  125. package/dist/chunk-2fe8jjdc.js +0 -22
  126. package/dist/chunk-3ejt4h7x.js.map +0 -10
  127. package/dist/chunk-6q8mr1sg.js +0 -13
  128. package/dist/chunk-6yrb30rx.js +0 -22
  129. package/dist/chunk-dgzw1nj9.js.map +0 -10
  130. package/dist/chunk-jd4q8fcf.js +0 -11
  131. package/dist/chunk-ty9bhdvx.js.map +0 -10
  132. package/dist/chunk-vep2byx4.js.map +0 -10
  133. package/dist/chunk-yn2sjcg3.js.map +0 -10
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectionMultiInstance.spec.d.ts","sourceRoot":"","sources":["../../src/tests/projectionMultiInstance.spec.ts"],"names":[],"mappings":""}
@@ -0,0 +1,19 @@
1
+ import type { AggregateRootSnapshotMethods } from '../createAggregateRoot.ts';
2
+ import type { CheckpointMethods } from '../createCheckpointApi.ts';
3
+ import type { LoadEvents } from '../createEventStore.ts';
4
+ import type { ProjectionSnapshotMethods } from '../createProjection.ts';
5
+ import type { BaseInputEvent, BaseOutputEvent, MaybePromise, StreamId } from '../utilityTypes.ts';
6
+ export type StorageDriver = {
7
+ loadEvents: LoadEvents;
8
+ appendEvents: (streamId: StreamId, events: BaseInputEvent[], expectedVersion: number) => MaybePromise<BaseOutputEvent[]>;
9
+ checkpoint: CheckpointMethods;
10
+ projectionSnapshot: ProjectionSnapshotMethods;
11
+ aggregateRootSnapshot: AggregateRootSnapshotMethods;
12
+ };
13
+ /**
14
+ * Reusable contract suite exercising the raw storage methods every driver
15
+ * must supply to the core `createEventStore`. Call it once per driver with a
16
+ * factory producing a fresh, empty storage instance.
17
+ */
18
+ export declare function runStorageDriverContract(name: string, makeStorage: () => MaybePromise<StorageDriver>): void;
19
+ //# sourceMappingURL=storageDriverContract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storageDriverContract.d.ts","sourceRoot":"","sources":["../../src/tests/storageDriverContract.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAEV,4BAA4B,EAC7B,MAAM,2BAA2B,CAAA;AAClC,OAAO,KAAK,EAAc,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAC9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAA;AACxD,OAAO,KAAK,EAEV,yBAAyB,EAC1B,MAAM,wBAAwB,CAAA;AAE/B,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,YAAY,EACZ,QAAQ,EACT,MAAM,oBAAoB,CAAA;AAE3B,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,UAAU,CAAA;IACtB,YAAY,EAAE,CACZ,QAAQ,EAAE,QAAQ,EAClB,MAAM,EAAE,cAAc,EAAE,EACxB,eAAe,EAAE,MAAM,KACpB,YAAY,CAAC,eAAe,EAAE,CAAC,CAAA;IACpC,UAAU,EAAE,iBAAiB,CAAA;IAC7B,kBAAkB,EAAE,yBAAyB,CAAA;IAC7C,qBAAqB,EAAE,4BAA4B,CAAA;CACpD,CAAA;AA8CD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,YAAY,CAAC,aAAa,CAAC,QAghB/C"}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createTransaction
3
- } from "./chunk-pc1q4se9.js";
3
+ } from "./chunk-9qtjz725.js";
4
4
  export {
5
5
  createTransaction as default
6
6
  };
package/dist/utils.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
- awaitAll,
3
- normalizeArray
4
- } from "./chunk-tfy01fah.js";
2
+ normalizeArray2,
3
+ awaitAll2
4
+ } from "./chunk-c7sytwpp.js";
5
5
  export {
6
- normalizeArray,
7
- awaitAll
6
+ awaitAll2 as awaitAll,
7
+ normalizeArray2 as normalizeArray
8
8
  };
9
9
 
10
- //# debugId=B02575DAA75C385064756E2164756E21
10
+ //# debugId=27F4432A2CD67A7364756E2164756E21
package/dist/utils.js.map CHANGED
@@ -4,6 +4,6 @@
4
4
  "sourcesContent": [
5
5
  ],
6
6
  "mappings": "",
7
- "debugId": "B02575DAA75C385064756E2164756E21",
7
+ "debugId": "27F4432A2CD67A7364756E2164756E21",
8
8
  "names": []
9
9
  }
@@ -1,12 +1,12 @@
1
1
  import {
2
- handleEventError,
3
- unwrapEvent,
4
- wrappedEventBuilder
5
- } from "./chunk-z388cd25.js";
2
+ unwrapEvent2,
3
+ handleEventError2,
4
+ wrappedEventBuilder2
5
+ } from "./chunk-zkz6ew8c.js";
6
6
  export {
7
- wrappedEventBuilder,
8
- unwrapEvent,
9
- handleEventError
7
+ handleEventError2 as handleEventError,
8
+ unwrapEvent2 as unwrapEvent,
9
+ wrappedEventBuilder2 as wrappedEventBuilder
10
10
  };
11
11
 
12
- //# debugId=AEFF0A7062715AE064756E2164756E21
12
+ //# debugId=13D61F145C1E7AE864756E2164756E21
@@ -4,6 +4,6 @@
4
4
  "sourcesContent": [
5
5
  ],
6
6
  "mappings": "",
7
- "debugId": "AEFF0A7062715AE064756E2164756E21",
7
+ "debugId": "13D61F145C1E7AE864756E2164756E21",
8
8
  "names": []
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@requence/event-sourcing",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Event sourcing primitives — aggregate roots, projections, process managers, and event listeners",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -37,6 +37,12 @@
37
37
  "types": "./dist/redis/index.d.ts",
38
38
  "import": "./dist/redis/index.js",
39
39
  "default": "./dist/redis/index.js"
40
+ },
41
+ "./surreal": {
42
+ "bun": "./src/surreal/index.ts",
43
+ "types": "./dist/surreal/index.d.ts",
44
+ "import": "./dist/surreal/index.js",
45
+ "default": "./dist/surreal/index.js"
40
46
  }
41
47
  },
42
48
  "files": [
@@ -49,6 +55,9 @@
49
55
  "build": "bun run build.ts",
50
56
  "test": "bun test",
51
57
  "check-types": "tsc --noEmit",
58
+ "lint": "oxlint",
59
+ "format": "oxfmt",
60
+ "format:check": "oxfmt --check",
52
61
  "cs": "changeset",
53
62
  "version": "changeset version",
54
63
  "release": "bun run build && changeset publish",
@@ -62,7 +71,8 @@
62
71
  "peerDependencies": {
63
72
  "zod": "^4.3.6",
64
73
  "drizzle-orm": "^0.45.1",
65
- "ioredis": "^5.9.3"
74
+ "ioredis": "^5.9.3",
75
+ "surrealdb": "^2.0.0"
66
76
  },
67
77
  "peerDependenciesMeta": {
68
78
  "drizzle-orm": {
@@ -70,24 +80,23 @@
70
80
  },
71
81
  "ioredis": {
72
82
  "optional": true
83
+ },
84
+ "surrealdb": {
85
+ "optional": true
73
86
  }
74
87
  },
75
88
  "devDependencies": {
76
89
  "@changesets/changelog-github": "^0.6.0",
77
90
  "@changesets/cli": "^2.31.0",
78
- "@eslint/js": "^10.0.1",
79
- "@stylistic/eslint-plugin": "^5.10.0",
91
+ "@surrealdb/node": "^3.0.3",
80
92
  "@types/bun": "^1.3.13",
81
93
  "@types/node": "^25.6.0",
82
94
  "drizzle-orm": "^0.45.1",
83
- "eslint": "^10.2.1",
84
- "eslint-plugin-import": "^2.32.0",
85
- "eslint-plugin-no-only-tests": "^3.3.0",
86
- "globals": "^17.5.0",
87
95
  "ioredis": "^5.10.1",
88
- "prettier": "^3.8.3",
96
+ "oxfmt": "^0.59.0",
97
+ "oxlint": "^1.74.0",
98
+ "surrealdb": "^2.0.7",
89
99
  "typescript": "^6.0.3",
90
- "typescript-eslint": "^8.59.1",
91
100
  "zod": "^4.3.6"
92
101
  },
93
102
  "license": "MIT",
@@ -17,7 +17,7 @@ import {
17
17
  InfiniteLoopError,
18
18
  ValidationError,
19
19
  } from './errors.ts'
20
- import type { LockCreator } from './lock.ts'
20
+ import { DEFAULT_LOCK_TTL, type LockCreator } from './lock.ts'
21
21
  import { isRefreshing } from './refresh.ts'
22
22
  import createSnapshotGenerator, {
23
23
  type CompleteSnapshotGenerator,
@@ -530,6 +530,7 @@ export function createAggregateRoot<Name extends string>(type: Name) {
530
530
  let downstreamSettledPromise = () => Promise.resolve()
531
531
  let releaseLock!: () => Promise<void>
532
532
  let extendLock!: EventHandlerOptions['extendLock']
533
+ let lockTtl: number | undefined
533
534
 
534
535
  const warnReleaseLock = () => {
535
536
  if (releasedAfterChain) {
@@ -582,8 +583,25 @@ export function createAggregateRoot<Name extends string>(type: Name) {
582
583
  let commandChain = createLock([type, streamId]).then((lock) => {
583
584
  releaseLock = lock.release
584
585
  extendLock = lock.extend
586
+ lockTtl = lock.ttl
585
587
  return [] as Error | BaseInputEvent[]
586
588
  })
589
+
590
+ // A replay is the library's own work and takes as long as the stream is
591
+ // long, so unlike a slow command it must not cost the caller the lock.
592
+ // Extended on a timer while it runs, and never past it: a lock nobody
593
+ // releases still expires.
594
+ const keepLockAlive = async <T>(work: () => Promise<T>) => {
595
+ const timer = setInterval(
596
+ () => void extendLock().catch(() => false),
597
+ (lockTtl ?? DEFAULT_LOCK_TTL) / 4,
598
+ )
599
+ try {
600
+ return await work()
601
+ } finally {
602
+ clearInterval(timer)
603
+ }
604
+ }
587
605
  let commandChainTouched = false
588
606
  let releasedAfterChain = false
589
607
  let transactionDelay: TransactionDelay | undefined
@@ -616,9 +634,8 @@ export function createAggregateRoot<Name extends string>(type: Name) {
616
634
  return previousEvents
617
635
  }
618
636
  const events = (
619
- await normalizeArray(
620
- commandFn.apply(rawCommands, args),
621
- () => extendLock(),
637
+ await normalizeArray(commandFn.apply(rawCommands, args), () =>
638
+ extendLock(),
622
639
  )
623
640
  ).filter((event) => Boolean(event))
624
641
 
@@ -770,7 +787,9 @@ export function createAggregateRoot<Name extends string>(type: Name) {
770
787
  // ensure commands are executed after load is finished
771
788
  commandChain = commandChain
772
789
  .then(() =>
773
- loadUntil(state, snapshotApi, streamId, undefined, filter),
790
+ keepLockAlive(() =>
791
+ loadUntil(state, snapshotApi, streamId, undefined, filter),
792
+ ),
774
793
  )
775
794
  .then((loadedVersion) => {
776
795
  version = loadedVersion
@@ -18,6 +18,14 @@ const checkpointSchema = z.object({
18
18
 
19
19
  export type Checkpoint = z.output<typeof checkpointSchema>
20
20
 
21
+ /**
22
+ * The outcome of {@link CheckpointApi.claim}: either this instance owns the
23
+ * event and must run its work, or another instance already has it.
24
+ */
25
+ export type ClaimResult =
26
+ | { claimed: false }
27
+ | { claimed: true; release: () => Promise<void> }
28
+
21
29
  export type CheckpointMethods = {
22
30
  get: (
23
31
  type: 'projection' | 'processManager',
@@ -57,12 +65,16 @@ export function createCheckpointApi(
57
65
  // every load/upsert so optimistic-concurrency writes can detect when another
58
66
  // instance has advanced the same checkpoint.
59
67
  let version: number | null = null
68
+ // The position that goes with `version`. Only {@link CheckpointApi.claim}
69
+ // needs it, to put the cursor back if the work it claimed then fails.
70
+ let position: number | null = null
60
71
 
61
72
  const api = {
62
73
  relatedStreamEvents: params.relatedStreamEvents,
63
74
  async clear() {
64
75
  await params.delete(params.type, params.name)
65
76
  version = null
77
+ position = null
66
78
  },
67
79
  /**
68
80
  * Load the persisted checkpoint and sync the local version. Returns the
@@ -72,8 +84,93 @@ export function createCheckpointApi(
72
84
  async load() {
73
85
  const checkpoint = await params.get(params.type, params.name)
74
86
  version = checkpoint?.version ?? null
87
+ position = checkpoint?.lastEventPosition ?? null
75
88
  return checkpoint ?? null
76
89
  },
90
+ /**
91
+ * Takes ownership of `event` *before* its work runs, so that exactly one
92
+ * instance does that work.
93
+ *
94
+ * Every instance runs its own copy of a projection against shared storage,
95
+ * and each decides what it has already applied from a cursor only it can
96
+ * see. Two instances catching up over the same backlog — or one catching up
97
+ * while the other applies an event it just appended — therefore both reach
98
+ * the same handler. Writing the cursor first, through the same
99
+ * compare-and-swap the ordinary write uses, is what breaks the tie: the
100
+ * loser is told so and skips the event instead of repeating its side
101
+ * effects.
102
+ *
103
+ * Returns `{ claimed: false }` when another writer already covers this
104
+ * position. On success the caller gets a `release` to call if the work then
105
+ * fails, which puts the cursor back so the event is picked up again rather
106
+ * than silently skipped — without it, claiming would turn every failed
107
+ * handler into a lost event.
108
+ */
109
+ async claim(
110
+ event: BaseOutputEvent,
111
+ metadata: Record<string, any> = {},
112
+ ): Promise<ClaimResult> {
113
+ while (true) {
114
+ const expectedVersion = version
115
+ const previousPosition = position
116
+ const nextVersion = (expectedVersion ?? -1) + 1
117
+
118
+ const committed = await params.upsert(
119
+ {
120
+ lastEventPosition: event.position,
121
+ type: params.type,
122
+ name: params.name,
123
+ metadata,
124
+ version: nextVersion,
125
+ },
126
+ expectedVersion,
127
+ )
128
+
129
+ if (committed) {
130
+ version = nextVersion
131
+ position = event.position
132
+
133
+ return {
134
+ claimed: true,
135
+ async release() {
136
+ // Only undo a claim that is still the newest write. Once anything
137
+ // else has moved the cursor, rewinding it would drop that work.
138
+ if (version !== nextVersion) {
139
+ return
140
+ }
141
+
142
+ if (previousPosition === null) {
143
+ await api.clear()
144
+ return
145
+ }
146
+
147
+ const restored = await params.upsert(
148
+ {
149
+ lastEventPosition: previousPosition,
150
+ type: params.type,
151
+ name: params.name,
152
+ metadata,
153
+ version: nextVersion + 1,
154
+ },
155
+ nextVersion,
156
+ )
157
+
158
+ if (restored) {
159
+ version = nextVersion + 1
160
+ position = previousPosition
161
+ }
162
+ },
163
+ }
164
+ }
165
+
166
+ // Another writer advanced this checkpoint. Refresh our view of it.
167
+ const current = await api.load()
168
+
169
+ if (current && current.lastEventPosition >= event.position) {
170
+ return { claimed: false }
171
+ }
172
+ }
173
+ },
77
174
  /**
78
175
  * Persist the checkpoint position (and optional metadata) for the given
79
176
  * event.
@@ -108,6 +205,7 @@ export function createCheckpointApi(
108
205
 
109
206
  if (committed) {
110
207
  version = nextVersion
208
+ position = event.position
111
209
  return
112
210
  }
113
211
 
@@ -293,7 +293,9 @@ export function buildProcessManagerCreator(params: {
293
293
  await hydrated
294
294
  },
295
295
  async beginSession() {
296
- if (!hasState) return
296
+ if (!hasState) {
297
+ return
298
+ }
297
299
  // Load through the checkpoint API so the local version is synced with
298
300
  // the store; the subsequent fold relies on it for compare-and-swap.
299
301
  const checkpoint = await checkpointApi.load()
@@ -310,7 +312,9 @@ export function buildProcessManagerCreator(params: {
310
312
  sessionActive = true
311
313
  },
312
314
  endSession() {
313
- if (!hasState) return
315
+ if (!hasState) {
316
+ return
317
+ }
314
318
  state = null
315
319
  sessionActive = false
316
320
  },
@@ -379,10 +383,7 @@ export function buildProcessManagerCreator(params: {
379
383
  async state() {
380
384
  await hydrated
381
385
  if (!sessionActive && hasState) {
382
- const checkpoint = await params.checkpoint.get(
383
- 'processManager',
384
- name,
385
- )
386
+ const checkpoint = await params.checkpoint.get('processManager', name)
386
387
  return checkpoint?.metadata?.state ?? clone(initialState)
387
388
  }
388
389
  return state
@@ -266,86 +266,112 @@ export function buildProjectionCreator(params: {
266
266
 
267
267
  const runner = (async () => {
268
268
  await unlocked
269
- await withProjectionInfo({ name, event }, async () => {
270
- try {
271
- await handler?.(event, {
272
- replaying,
273
- async replayUntil(position, filterEvents) {
274
- if (position >= event.position) {
275
- throw new ProjectionReplayLoopError(
276
- `Event handler on${event.type} of projection ${name} tried to replay to the future`,
277
- { name, eventHandler: `on${event.type}` },
278
- event,
279
- )
269
+
270
+ // Take ownership of the event before anything observable happens, so a
271
+ // peer applying the same event does not apply it twice. `claimed` is
272
+ // null while replaying: a replay deliberately re-applies everything and
273
+ // `beginReplay` leaves the stored cursor where it was, so a claim would
274
+ // read that not-yet-rewound position and skip the whole run.
275
+ const claimed = isReplaying() ? null : await checkpointApi.claim(event)
276
+
277
+ if (claimed && !claimed.claimed) {
278
+ return
279
+ }
280
+
281
+ const release = claimed?.claimed ? claimed.release : null
282
+
283
+ try {
284
+ await withProjectionInfo({ name, event }, async () => {
285
+ try {
286
+ await handler?.(event, {
287
+ replaying,
288
+ async replayUntil(position, filterEvents) {
289
+ if (position >= event.position) {
290
+ throw new ProjectionReplayLoopError(
291
+ `Event handler on${event.type} of projection ${name} tried to replay to the future`,
292
+ { name, eventHandler: `on${event.type}` },
293
+ event,
294
+ )
295
+ }
296
+
297
+ locks.delete(event.streamId)
298
+ return api.replayUntil(event.streamId, position, filterEvents)
299
+ },
300
+ })
301
+ } catch (error) {
302
+ if (error instanceof ReplaySkipError) {
303
+ if (isReplaying()) {
304
+ return
280
305
  }
281
306
 
282
- locks.delete(event.streamId)
283
- return api.replayUntil(event.streamId, position, filterEvents)
284
- },
285
- })
286
- } catch (error) {
287
- if (error instanceof ReplaySkipError) {
288
- if (isReplaying()) {
289
- return
307
+ throw new Error(
308
+ 'ReplaySkipError should never be thrown outside replay',
309
+ )
290
310
  }
291
311
 
292
- throw new Error(
293
- 'ReplaySkipError should never be thrown outside replay',
312
+ if (
313
+ !(error instanceof Error) ||
314
+ error instanceof ProjectionReplayLoopError
315
+ ) {
316
+ throw error
317
+ }
318
+ throw new ProjectionEventHandlerExecutionError(
319
+ name,
320
+ `on${event.type}`,
321
+ event,
322
+ error,
294
323
  )
295
324
  }
325
+ try {
326
+ await globalEventHandler?.(event)
327
+ } catch (error) {
328
+ if (!(error instanceof Error)) {
329
+ throw error
330
+ }
296
331
 
297
- if (
298
- !(error instanceof Error) ||
299
- error instanceof ProjectionReplayLoopError
300
- ) {
301
- throw error
302
- }
303
- throw new ProjectionEventHandlerExecutionError(
304
- name,
305
- `on${event.type}`,
306
- event,
307
- error,
308
- )
309
- }
310
- try {
311
- await globalEventHandler?.(event)
312
- } catch (error) {
313
- if (!(error instanceof Error)) {
314
- throw error
332
+ throw new ProjectionEventHandlerExecutionError(
333
+ name,
334
+ 'GLOBAL',
335
+ event,
336
+ error,
337
+ )
315
338
  }
339
+ })
316
340
 
317
- throw new ProjectionEventHandlerExecutionError(
318
- name,
319
- 'GLOBAL',
341
+ if (params.snapshot && snapshotting) {
342
+ const appliedEvents = await params.snapshot.incrementAppliedCount({
343
+ projection: name,
344
+ id: event.streamId,
345
+ })
346
+ const snapshot = await snapshotting.internalCapture(
320
347
  event,
321
- error,
348
+ appliedEvents,
322
349
  )
323
- }
324
- })
325
-
326
- if (params.snapshot && snapshotting) {
327
- const appliedEvents = await params.snapshot.incrementAppliedCount({
328
- projection: name,
329
- id: event.streamId,
330
- })
331
- const snapshot = await snapshotting.internalCapture(
332
- event,
333
- appliedEvents,
334
- )
335
350
 
336
- if (snapshot) {
337
- await params.snapshot.put({
338
- projectionType: name,
339
- projectionId: event.streamId,
340
- lastEventPosition: event.position,
341
- state: snapshot.state,
342
- schemaVersion: snapshot.version,
343
- createdAt: new Date(),
344
- })
351
+ if (snapshot) {
352
+ await params.snapshot.put({
353
+ projectionType: name,
354
+ projectionId: event.streamId,
355
+ lastEventPosition: event.position,
356
+ state: snapshot.state,
357
+ schemaVersion: snapshot.version,
358
+ createdAt: new Date(),
359
+ })
360
+ }
345
361
  }
362
+ } catch (error) {
363
+ // The cursor was moved before this work ran, so it has to go back or
364
+ // the event would never be retried — a claim must not turn a failing
365
+ // handler into a silently skipped one.
366
+ await release?.()
367
+ throw error
346
368
  }
347
369
 
348
- await checkpointApi.upsert(event)
370
+ // A claim already wrote the cursor. A replay did not take one, so it
371
+ // still advances the cursor the ordinary way.
372
+ if (!release) {
373
+ await checkpointApi.upsert(event)
374
+ }
349
375
  })()
350
376
 
351
377
  if (exclusive) {
package/src/lock.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  import { Mutex } from 'async-mutex'
2
2
 
3
- export default function lock(defaultTtl = 5000) {
3
+ export const DEFAULT_LOCK_TTL = 5000
4
+
5
+ export type Lock = {
6
+ /** Moves the expiry to `ttl` from now (default: half the lock's TTL). */
7
+ extend(ttl?: number): Promise<boolean>
8
+ release(): Promise<void>
9
+ /**
10
+ * The TTL the lock was taken with. The aggregate root keeps a replay's lock
11
+ * alive at a quarter of it, and assumes {@link DEFAULT_LOCK_TTL} without it.
12
+ */
13
+ ttl?: number
14
+ }
15
+
16
+ export type LockCreator = (key: string | string[]) => Promise<Lock>
17
+
18
+ export default function lock(defaultTtl = DEFAULT_LOCK_TTL): LockCreator {
4
19
  const locks = new Map<string, Mutex>()
5
20
  return async (key: string | string[]) => {
6
21
  const k = Array.isArray(key) ? key.join(':') : key
@@ -30,6 +45,7 @@ export default function lock(defaultTtl = 5000) {
30
45
  startTimer(defaultTtl)
31
46
 
32
47
  return {
48
+ ttl: defaultTtl,
33
49
  async extend(ttl?: number) {
34
50
  if (isReleased) {
35
51
  return false
@@ -54,5 +70,3 @@ export default function lock(defaultTtl = 5000) {
54
70
  }
55
71
  }
56
72
  }
57
-
58
- export type LockCreator = ReturnType<typeof lock>
@@ -0,0 +1,4 @@
1
+ import { runStorageDriverContract } from '../tests/storageDriverContract.ts'
2
+ import { createMemoryStorage } from './index.ts'
3
+
4
+ runStorageDriverContract('memory', () => createMemoryStorage())