@kairos-es/core 1.0.0-alpha.3 → 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/dist/cjs/ContractStore.js +11 -2
- package/dist/cjs/ContractStore.js.map +1 -1
- package/dist/cjs/Event.js +63 -3
- package/dist/cjs/Event.js.map +1 -1
- package/dist/cjs/Projection.js +15 -9
- package/dist/cjs/Projection.js.map +1 -1
- package/dist/cjs/Query.js +58 -2
- package/dist/cjs/Query.js.map +1 -1
- package/dist/cjs/inMemory.js +110 -40
- package/dist/cjs/inMemory.js.map +1 -1
- package/dist/dts/ContractStore.d.ts +4 -2
- package/dist/dts/ContractStore.d.ts.map +1 -1
- package/dist/dts/Event.d.ts +60 -1
- package/dist/dts/Event.d.ts.map +1 -1
- package/dist/dts/Projection.d.ts +1 -1
- package/dist/dts/Projection.d.ts.map +1 -1
- package/dist/dts/Query.d.ts +14 -0
- package/dist/dts/Query.d.ts.map +1 -1
- package/dist/dts/inMemory.d.ts.map +1 -1
- package/dist/esm/ContractStore.js +11 -2
- package/dist/esm/ContractStore.js.map +1 -1
- package/dist/esm/Event.js +62 -2
- package/dist/esm/Event.js.map +1 -1
- package/dist/esm/Projection.js +15 -9
- package/dist/esm/Projection.js.map +1 -1
- package/dist/esm/Query.js +55 -0
- package/dist/esm/Query.js.map +1 -1
- package/dist/esm/inMemory.js +111 -41
- package/dist/esm/inMemory.js.map +1 -1
- package/package.json +1 -1
- package/src/ContractStore.ts +20 -4
- package/src/Event.ts +63 -1
- package/src/Projection.ts +19 -8
- package/src/Query.ts +73 -0
- package/src/inMemory.ts +119 -47
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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,
|
|
394
|
+
fetchPage(snapshot, judged, from, limit),
|
|
323
395
|
),
|
|
324
396
|
listen: Stream.fromQueue(wakes),
|
|
325
397
|
pollInterval,
|