@kairos-es/core 1.0.0-alpha.2 → 1.0.0-alpha.4

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/src/inMemory.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * discharges the ascending-commit-order precondition `SubscribeMachine.ts` states.
26
26
  */
27
27
  import type { Array as Arr, Scope } from 'effect'
28
- import { Effect, Layer, PubSub, Ref, Stream } from 'effect'
28
+ import { DateTime, Effect, Layer, PubSub, Ref, Stream } from 'effect'
29
29
  import type { AppendCondition } from './AppendCondition'
30
30
  import { makeContractStore, type RawDcbEventStore } from './ContractStore'
31
31
  import type { ReadOptions, ReadResponse } from './DcbEventStore'
@@ -41,6 +41,68 @@ import {
41
41
  subscribeStream,
42
42
  } from './SubscribeMachine'
43
43
 
44
+ /**
45
+ * A copy of `event` that shares no mutable structure with it: fresh `data` bytes, a
46
+ * fresh `tags` array, a fresh `metadata` bag and a fresh `occurredAt`. Only `type`
47
+ * and `uuid`, which are strings, are carried as they are.
48
+ *
49
+ * Each copy is chosen for what it does on EVERY input the type admits, not on the
50
+ * common one. `data` is copied through the `Uint8Array` constructor and not with
51
+ * `slice`, because `DcbEvent` admits any `Uint8Array` subclass and a subclass may
52
+ * answer `slice` with a VIEW of the same memory — Node's `Buffer` does. A
53
+ * `DateTime.Utc` is an ordinary object whose fields can be written past its
54
+ * `readonly` type, so `occurredAt` is rebuilt from its instant rather than shared.
55
+ *
56
+ * ## Why the oracle copies at BOTH edges
57
+ *
58
+ * This store is the reference the SQL engines are judged against, and a SQL engine
59
+ * shares nothing with its caller: it serialises on the way in and builds fresh objects
60
+ * on the way out. A store that kept the caller's objects instead would let a caller
61
+ * rewrite the log AFTER the append — mutate a `Uint8Array`, push to a `tags` array,
62
+ * assign into a `metadata` bag — and every later read would see the rewritten event
63
+ * with no append, no position and no wake behind it. A reference that can be edited
64
+ * after the fact judges nothing.
65
+ *
66
+ * So `append` stores a copy (the log never holds a caller's object) and every read
67
+ * path hands out a copy (a reader cannot reach the log through what it was given).
68
+ * Copying on the way in alone would close the first route and leave the second open:
69
+ * the same object would then be shared by every reader of the log.
70
+ */
71
+ const copyEvent = (event: DcbEvent): DcbEvent => ({
72
+ type: event.type,
73
+ data: new Uint8Array(event.data),
74
+ tags: [...event.tags],
75
+ uuid: event.uuid,
76
+ occurredAt: DateTime.unsafeMake(DateTime.toEpochMillis(event.occurredAt)),
77
+ metadata: { ...event.metadata },
78
+ })
79
+
80
+ /**
81
+ * A copy of `query` that shares no array with it, taking only the keys the grammar
82
+ * defines.
83
+ *
84
+ * `subscribe` is the one verb that holds a query beyond its own call: the fetch seam
85
+ * judges it again on every page for as long as the subscription runs. Each SQL engine
86
+ * compiles its plan once, when the subscription starts, so an edit the caller makes
87
+ * to its own object afterwards reaches nothing there. The oracle has to answer the
88
+ * same, for the reason it copies events (above): a reference that the caller can
89
+ * change mid-stream judges nothing. Only `types` and `tags` are carried because they
90
+ * are all `matchesQueryItem` reads, and a key the grammar does not define was
91
+ * already refused at the contract decorator before this runs.
92
+ */
93
+ const copyQuery = (query: Query): Query => ({
94
+ items: query.items.map((item) => ({
95
+ types: [...item.types],
96
+ tags: [...item.tags],
97
+ })),
98
+ })
99
+
100
+ /** A `SequencedEvent` whose event is a `copyEvent` of the stored one. */
101
+ const copySequenced = (sequenced: SequencedEvent): SequencedEvent => ({
102
+ event: copyEvent(sequenced.event),
103
+ position: sequenced.position,
104
+ })
105
+
44
106
  /** Filter a snapshot by `query` and `after` (strict `position > after`). */
45
107
  const filterLog = (
46
108
  log: ReadonlyArray<SequencedEvent>,
@@ -80,7 +142,7 @@ const readSnapshot = (
80
142
  ? lastPosition(log)
81
143
  : lastPosition(limited)) ?? ORIGIN
82
144
 
83
- return { events: Stream.fromIterable(limited), head }
145
+ return { events: Stream.fromIterable(limited.map(copySequenced)), head }
84
146
  }
85
147
 
86
148
  /**
@@ -144,7 +206,7 @@ const fetchPage = (
144
206
  ) {
145
207
  const sequenced = log[index]
146
208
  if (matchesSequenced(sequenced, query)) {
147
- page.push(sequenced)
209
+ page.push(copySequenced(sequenced))
148
210
  }
149
211
  }
150
212
  return page
@@ -247,53 +309,60 @@ const make = (
247
309
  events: Arr.NonEmptyReadonlyArray<DcbEvent>,
248
310
  condition?: AppendCondition,
249
311
  ): Effect.Effect<Position, AppendConditionFailed> =>
250
- // Critical section: check + commit are atomic (the exactly-one-wins guarantee).
251
- mutex.withPermits(1)(
252
- Effect.gen(function* () {
253
- const snapshot = yield* Ref.get(log)
312
+ Effect.suspend(() => {
313
+ // The events are copied when the append STARTS, before it waits for the
314
+ // permit, as each SQL engine serialises them before its first suspension:
315
+ // an edit the caller makes while this append is queued must not reach the
316
+ // log here when it could not reach theirs.
317
+ const owned = events.map(copyEvent)
318
+ // Critical section: check + commit are atomic (the exactly-one-wins guarantee).
319
+ return mutex.withPermits(1)(
320
+ Effect.gen(function* () {
321
+ const snapshot = yield* Ref.get(log)
254
322
 
255
- if (condition !== undefined) {
256
- const conflicts = filterLog(
257
- snapshot,
258
- condition.failIfEventsMatch,
259
- condition.after,
260
- )
261
- // Detect by presence: any matching event after `condition.after`
262
- // fails the append. The error carries only the condition (see
263
- // `AppendConditionFailed`); a correct retry re-reads to the new head.
264
- if (conflicts.length > 0) {
265
- return yield* new AppendConditionFailed({ condition })
323
+ if (condition !== undefined) {
324
+ const conflicts = filterLog(
325
+ snapshot,
326
+ condition.failIfEventsMatch,
327
+ condition.after,
328
+ )
329
+ // Detect by presence: any matching event after `condition.after`
330
+ // fails the append. The error carries only the condition (see
331
+ // `AppendConditionFailed`); a correct retry re-reads to the new head.
332
+ if (conflicts.length > 0) {
333
+ return yield* new AppendConditionFailed({ condition })
334
+ }
266
335
  }
267
- }
268
336
 
269
- // Optional test seam between the conflict-check and the commit (see
270
- // `InMemoryStoreOptions.beforeCommit`). Defaults to `Effect.void`, a
271
- // synchronous no-op, so the shipped critical section stays atomic by
272
- // construction; the concurrency contract test injects `Effect.yieldNow()`
273
- // here to make the exactly-one-wins guard genuinely depend on the mutex.
274
- yield* beforeCommit
337
+ // Optional test seam between the conflict-check and the commit (see
338
+ // `InMemoryStoreOptions.beforeCommit`). Defaults to `Effect.void`, a
339
+ // synchronous no-op, so the shipped critical section stays atomic by
340
+ // construction; the concurrency contract test injects `Effect.yieldNow()`
341
+ // here to make the exactly-one-wins guard genuinely depend on the mutex.
342
+ yield* beforeCommit
275
343
 
276
- const start = yield* Ref.get(nextPosition)
277
- const appended: Array<SequencedEvent> = events.map(
278
- (event, index) => ({
279
- event,
280
- position: (start + BigInt(index)) as Position,
281
- }),
282
- )
283
- const end = start + BigInt(events.length)
284
- yield* Ref.set(log, [...snapshot, ...appended])
285
- yield* Ref.set(nextPosition, end)
286
- // The hub is a WAKE SOURCE and nothing else: the subscribe machine never
287
- // reads what it carries, and its poll delivers with or without one. That
288
- // is why suppressing the publish is a legitimate test seam rather than a
289
- // broken store (see `InMemoryStoreOptions.suppressWake`).
290
- if (!suppressWake) {
291
- yield* PubSub.publishAll(hub, appended)
292
- }
293
- // `events` is non-empty, so the last new position is `end - 1`.
294
- return (end - 1n) as Position
295
- }),
296
- )
344
+ const start = yield* Ref.get(nextPosition)
345
+ const appended: Array<SequencedEvent> = owned.map(
346
+ (event, index) => ({
347
+ event,
348
+ position: (start + BigInt(index)) as Position,
349
+ }),
350
+ )
351
+ const end = start + BigInt(events.length)
352
+ yield* Ref.set(log, [...snapshot, ...appended])
353
+ yield* Ref.set(nextPosition, end)
354
+ // The hub is a WAKE SOURCE and nothing else: the subscribe machine never
355
+ // reads what it carries, and its poll delivers with or without one. That
356
+ // is why suppressing the publish is a legitimate test seam rather than a
357
+ // broken store (see `InMemoryStoreOptions.suppressWake`).
358
+ if (!suppressWake) {
359
+ yield* PubSub.publishAll(hub, appended)
360
+ }
361
+ // `events` is non-empty, so the last new position is `end - 1`.
362
+ return (end - 1n) as Position
363
+ }),
364
+ )
365
+ })
297
366
 
298
367
  const subscribe = (
299
368
  query: Query,
@@ -314,12 +383,15 @@ const make = (
314
383
  // permit, which is the whole exactly-one-wins guarantee.
315
384
  Stream.unwrapScoped(
316
385
  Effect.gen(function* () {
386
+ // Taken when the subscription starts, as the SQL engines compile theirs
387
+ // (see `copyQuery`).
388
+ const judged = copyQuery(query)
317
389
  const wakes = yield* PubSub.subscribe(hub)
318
390
  return subscribeStream<never>(
319
391
  {
320
392
  fetch: (from, limit) =>
321
393
  Effect.map(Ref.get(log), (snapshot) =>
322
- fetchPage(snapshot, query, from, limit),
394
+ fetchPage(snapshot, judged, from, limit),
323
395
  ),
324
396
  listen: Stream.fromQueue(wakes),
325
397
  pollInterval,