@kairos-es/core 1.0.0-alpha.3 → 1.0.0-alpha.5

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 (120) hide show
  1. package/README.md +3 -3
  2. package/dist/cjs/AppendCondition.js +1 -3
  3. package/dist/cjs/AppendCondition.js.map +1 -1
  4. package/dist/cjs/AppendIsOwned.js +6 -6
  5. package/dist/cjs/AppendIsOwned.js.map +1 -1
  6. package/dist/cjs/ContractStore.js +13 -4
  7. package/dist/cjs/ContractStore.js.map +1 -1
  8. package/dist/cjs/DcbEventStore.js +1 -1
  9. package/dist/cjs/DcbEventStore.js.map +1 -1
  10. package/dist/cjs/Event.js +87 -11
  11. package/dist/cjs/Event.js.map +1 -1
  12. package/dist/cjs/Projection.js +15 -9
  13. package/dist/cjs/Projection.js.map +1 -1
  14. package/dist/cjs/Query.js +59 -3
  15. package/dist/cjs/Query.js.map +1 -1
  16. package/dist/cjs/SubscribeMachine.js +20 -22
  17. package/dist/cjs/SubscribeMachine.js.map +1 -1
  18. package/dist/cjs/inMemory.js +113 -43
  19. package/dist/cjs/inMemory.js.map +1 -1
  20. package/dist/cjs/retryOnConflict.js +4 -10
  21. package/dist/cjs/retryOnConflict.js.map +1 -1
  22. package/dist/cjs/testing.js +2 -2
  23. package/dist/cjs/testing.js.map +1 -1
  24. package/dist/{dts → dts-cjs}/AppendCondition.d.ts +5 -7
  25. package/dist/{dts → dts-cjs}/AppendCondition.d.ts.map +1 -1
  26. package/dist/{dts → dts-cjs}/AppendIsOwned.d.ts +6 -6
  27. package/dist/{dts → dts-cjs}/AppendIsOwned.d.ts.map +1 -1
  28. package/dist/{dts → dts-cjs}/ContractStore.d.ts +7 -4
  29. package/dist/dts-cjs/ContractStore.d.ts.map +1 -0
  30. package/dist/{dts → dts-cjs}/DcbEventStore.d.ts +2 -2
  31. package/dist/{dts → dts-cjs}/DcbEventStore.d.ts.map +1 -1
  32. package/dist/dts-cjs/Event.d.ts +174 -0
  33. package/dist/dts-cjs/Event.d.ts.map +1 -0
  34. package/dist/{dts → dts-cjs}/Projection.d.ts +1 -1
  35. package/dist/{dts → dts-cjs}/Projection.d.ts.map +1 -1
  36. package/dist/{dts → dts-cjs}/Query.d.ts +19 -5
  37. package/dist/dts-cjs/Query.d.ts.map +1 -0
  38. package/dist/{dts → dts-cjs}/SubscribeMachine.d.ts +11 -15
  39. package/dist/dts-cjs/SubscribeMachine.d.ts.map +1 -0
  40. package/dist/{dts → dts-cjs}/inMemory.d.ts.map +1 -1
  41. package/dist/{dts → dts-cjs}/index.d.ts +1 -1
  42. package/dist/dts-cjs/package.json +3 -0
  43. package/dist/{dts → dts-cjs}/retryOnConflict.d.ts +1 -7
  44. package/dist/dts-cjs/retryOnConflict.d.ts.map +1 -0
  45. package/dist/{dts → dts-cjs}/testing.d.ts +1 -1
  46. package/dist/dts-esm/AppendCondition.d.ts +21 -0
  47. package/dist/dts-esm/AppendCondition.d.ts.map +1 -0
  48. package/dist/dts-esm/AppendIsOwned.d.ts +78 -0
  49. package/dist/dts-esm/AppendIsOwned.d.ts.map +1 -0
  50. package/dist/dts-esm/ContractStore.d.ts +62 -0
  51. package/dist/dts-esm/ContractStore.d.ts.map +1 -0
  52. package/dist/dts-esm/DcbEventStore.d.ts +64 -0
  53. package/dist/dts-esm/DcbEventStore.d.ts.map +1 -0
  54. package/dist/dts-esm/Event.d.ts +174 -0
  55. package/dist/dts-esm/Event.d.ts.map +1 -0
  56. package/dist/dts-esm/Projection.d.ts +124 -0
  57. package/dist/dts-esm/Projection.d.ts.map +1 -0
  58. package/dist/dts-esm/Query.d.ts +180 -0
  59. package/dist/dts-esm/Query.d.ts.map +1 -0
  60. package/dist/dts-esm/SubscribeMachine.d.ts +175 -0
  61. package/dist/dts-esm/SubscribeMachine.d.ts.map +1 -0
  62. package/dist/dts-esm/errors.d.ts +17 -0
  63. package/dist/dts-esm/errors.d.ts.map +1 -0
  64. package/dist/dts-esm/inMemory.d.ts +76 -0
  65. package/dist/dts-esm/inMemory.d.ts.map +1 -0
  66. package/dist/dts-esm/index.d.ts +40 -0
  67. package/dist/dts-esm/index.d.ts.map +1 -0
  68. package/dist/dts-esm/package.json +3 -0
  69. package/dist/dts-esm/retryOnConflict.d.ts +64 -0
  70. package/dist/dts-esm/retryOnConflict.d.ts.map +1 -0
  71. package/dist/dts-esm/testing.d.ts +39 -0
  72. package/dist/dts-esm/testing.d.ts.map +1 -0
  73. package/dist/esm/AppendCondition.js +1 -3
  74. package/dist/esm/AppendCondition.js.map +1 -1
  75. package/dist/esm/AppendIsOwned.js +6 -6
  76. package/dist/esm/AppendIsOwned.js.map +1 -1
  77. package/dist/esm/ContractStore.js +13 -4
  78. package/dist/esm/ContractStore.js.map +1 -1
  79. package/dist/esm/DcbEventStore.js +1 -1
  80. package/dist/esm/DcbEventStore.js.map +1 -1
  81. package/dist/esm/Event.js +87 -11
  82. package/dist/esm/Event.js.map +1 -1
  83. package/dist/esm/Projection.js +15 -9
  84. package/dist/esm/Projection.js.map +1 -1
  85. package/dist/esm/Query.js +56 -1
  86. package/dist/esm/Query.js.map +1 -1
  87. package/dist/esm/SubscribeMachine.js +21 -23
  88. package/dist/esm/SubscribeMachine.js.map +1 -1
  89. package/dist/esm/inMemory.js +114 -44
  90. package/dist/esm/inMemory.js.map +1 -1
  91. package/dist/esm/index.js +1 -1
  92. package/dist/esm/retryOnConflict.js +4 -10
  93. package/dist/esm/retryOnConflict.js.map +1 -1
  94. package/dist/esm/testing.js +2 -2
  95. package/dist/esm/testing.js.map +1 -1
  96. package/package.json +21 -11
  97. package/src/AppendCondition.ts +1 -1
  98. package/src/AppendIsOwned.ts +7 -7
  99. package/src/ContractStore.ts +25 -8
  100. package/src/DcbEventStore.ts +3 -3
  101. package/src/Event.ts +108 -11
  102. package/src/Projection.ts +19 -8
  103. package/src/Query.ts +74 -1
  104. package/src/SubscribeMachine.ts +38 -31
  105. package/src/inMemory.ts +123 -51
  106. package/src/index.ts +1 -1
  107. package/src/retryOnConflict.ts +6 -11
  108. package/src/testing.ts +2 -2
  109. package/testing/package.json +1 -1
  110. package/dist/dts/ContractStore.d.ts.map +0 -1
  111. package/dist/dts/Event.d.ts +0 -104
  112. package/dist/dts/Event.d.ts.map +0 -1
  113. package/dist/dts/Query.d.ts.map +0 -1
  114. package/dist/dts/SubscribeMachine.d.ts.map +0 -1
  115. package/dist/dts/retryOnConflict.d.ts.map +0 -1
  116. /package/dist/{dts → dts-cjs}/errors.d.ts +0 -0
  117. /package/dist/{dts → dts-cjs}/errors.d.ts.map +0 -0
  118. /package/dist/{dts → dts-cjs}/inMemory.d.ts +0 -0
  119. /package/dist/{dts → dts-cjs}/index.d.ts.map +0 -0
  120. /package/dist/{dts → dts-cjs}/testing.d.ts.map +0 -0
package/src/Event.ts CHANGED
@@ -6,19 +6,55 @@
6
6
  * payloads into these bytes. Ordering is by `position` alone; `occurredAt` is
7
7
  * producer-supplied domain time and is informational only.
8
8
  */
9
- import { Schema } from 'effect'
9
+ import {
10
+ BigInt as BigInt_,
11
+ Effect,
12
+ Option,
13
+ Schema,
14
+ SchemaIssue,
15
+ SchemaTransformation,
16
+ } from 'effect'
10
17
 
11
18
  /**
12
19
  * A position in the single global event log: a branded, non-negative `bigint`.
13
20
  *
14
21
  * The log is 64-bit (`bigserial` in Postgres) and exceeds JS `number` safety at
15
22
  * 2^53, so positions are a `bigint`. They are compared, never arithmetic'd.
16
- * `Schema.BigInt` encodes to a decimal string (JSON-safe) and decodes to
23
+ * Encoded, a position is a decimal string (JSON-safe, `String(b)`); decoded, a
17
24
  * `bigint`. `0n` is the "before the first event" sentinel used by `after` and by
18
25
  * an empty store's head.
26
+ *
27
+ * The string-to-`bigint` step is hand-built rather than Effect 4's
28
+ * `Schema.BigIntFromString`, because that schema accepts only `^-?\d+$` and so
29
+ * refuses inputs the encoded side has always admitted — `" 5"`, `"+5"`,
30
+ * `"0x10"`. The decode here is the global `BigInt(s)` behind a blank-string
31
+ * refusal, which is `BigInt.fromString` and is exactly what Effect 3's
32
+ * `Schema.BigInt` ran, so a position string any earlier release decoded still
33
+ * decodes to the same value. The non-negative check then runs on the `bigint`,
34
+ * as it always did.
19
35
  */
20
- export const Position = Schema.BigInt.pipe(
21
- Schema.nonNegativeBigInt(),
36
+ export const Position = Schema.String.pipe(
37
+ Schema.decodeTo(
38
+ Schema.BigInt,
39
+ SchemaTransformation.transformEffect<bigint, string>({
40
+ decode: (s, options) =>
41
+ Option.match(BigInt_.fromString(s), {
42
+ onNone: () =>
43
+ Effect.fail(
44
+ new SchemaIssue.InvalidValue(
45
+ {
46
+ message: `Unable to decode ${JSON.stringify(s)} into a bigint`,
47
+ },
48
+ s,
49
+ options,
50
+ ),
51
+ ),
52
+ onSome: Effect.succeed,
53
+ }),
54
+ encode: (b) => Effect.succeed(String(b)),
55
+ }),
56
+ ),
57
+ Schema.check(Schema.isGreaterThanOrEqualToBigInt(0n)),
22
58
  Schema.brand('Position'),
23
59
  )
24
60
  export type Position = typeof Position.Type
@@ -33,7 +69,7 @@ export const ORIGIN: Position = Position.make(0n)
33
69
  * item's tags (superset test).
34
70
  */
35
71
  export const Tag = Schema.String.pipe(
36
- Schema.pattern(/^[^:]+:.+/, { identifier: 'Tag' }),
72
+ Schema.check(Schema.isPattern(/^[^:]+:.+/, { identifier: 'Tag' })),
37
73
  Schema.brand('Tag'),
38
74
  )
39
75
  export type Tag = typeof Tag.Type
@@ -95,7 +131,7 @@ export const tagIdentity = (key: string): TagIdentity => {
95
131
  * `ReadLimit.make(n)`.
96
132
  */
97
133
  export const ReadLimit = Schema.Int.pipe(
98
- Schema.greaterThanOrEqualTo(0),
134
+ Schema.check(Schema.isGreaterThanOrEqualTo(0)),
99
135
  Schema.brand('ReadLimit'),
100
136
  )
101
137
  export type ReadLimit = typeof ReadLimit.Type
@@ -105,25 +141,86 @@ export type ReadLimit = typeof ReadLimit.Type
105
141
  * in the name (e.g. `course-registered.v2`); only non-emptiness is enforced.
106
142
  */
107
143
  export const EventType = Schema.String.pipe(
108
- Schema.nonEmptyString(),
144
+ Schema.check(Schema.isNonEmpty()),
109
145
  Schema.brand('EventType'),
110
146
  )
111
147
  export type EventType = typeof EventType.Type
112
148
 
149
+ /**
150
+ * An event's metadata: a flat bag of strings carrying the circumstances under
151
+ * which the event was RECORDED, never anything the business asserts (ADR-0017).
152
+ * Payload is what happened; metadata is the context of the recording — a
153
+ * `traceparent`, a correlation or causation id, a deployment revision.
154
+ *
155
+ * Values are strings, not `unknown`, for cross-engine parity rather than taste:
156
+ * both SQL engines carry the bag as a JSON document, and `JSON.stringify` drops
157
+ * `undefined`, throws on `bigint` and turns `NaN` into `null`, none of which the
158
+ * in-memory oracle does.
159
+ *
160
+ * The key schema carries no refinement, deliberately: a refinement on a
161
+ * `Schema.Record` key acts as a FILTER on decode, silently dropping a key that
162
+ * fails it rather than failing the decode. Constraining keys, if ever wanted,
163
+ * has to be a validating step of its own.
164
+ *
165
+ * Two engine limits a consumer must know, because the bag is typically filled
166
+ * from outside the application at a request edge. Postgres refuses a NUL
167
+ * character (U+0000) and an unpaired surrogate in any string it stores, so an
168
+ * event whose bag holds one fails to append there while the in-memory and
169
+ * SQLite engines accept it; sanitise untrusted values before they reach the
170
+ * bag. And key ORDER is not preserved: Postgres stores an object's keys in an
171
+ * order of its own, so compare bags as unordered and never rely on the order
172
+ * `Object.keys` returns.
173
+ *
174
+ * Metadata is never part of a `Query` or an `AppendCondition` — a store refuses
175
+ * either when it carries a key the grammar does not define, so a metadata
176
+ * predicate cannot be written and silently ignored — never reaches a codec's
177
+ * `tagsFor`, and no store offers a way to rewrite it once appended. ADR-0017
178
+ * owns why each of those is mechanical rather than preferential.
179
+ *
180
+ * ## The one home of the shape rule
181
+ *
182
+ * The bag is a JSON OBJECT whose every value is a STRING, and this schema is
183
+ * where that rule is stated. At runtime it is checked in ONE place, at write: the
184
+ * codec's `toEvent` decodes every bag through this schema and refuses one that
185
+ * fails on its own error channel, so a value smuggled past the type (through an
186
+ * `any`) stops there. The schema judges a bag's OWN enumerable keys, so an object
187
+ * that keeps its entries anywhere else — a `Map`, a `Date` — reads as the empty
188
+ * bag rather than failing. A store checks no values: an event built by hand and handed
189
+ * to `append` directly carries whatever it carries, and what each engine then does
190
+ * with a non-string value differs — one may store it and read it back differently
191
+ * from another, and a `bigint` makes a SQL engine's append fail. Each
192
+ * SQL engine keeps a container check of its own on read — the column must hold a
193
+ * JSON object — because that defends against a writer outside the library and is
194
+ * about the column, not the values. A VALUE check must never be added on read
195
+ * alone: it would make an event the store accepted unreadable for ever, so a
196
+ * value check belongs at write or nowhere.
197
+ */
198
+ export const Metadata = Schema.Record(Schema.String, Schema.String)
199
+ export type Metadata = typeof Metadata.Type
200
+
113
201
  /**
114
202
  * An event as stored: an opaque payload plus its type, tags, a
115
- * producer-supplied `uuid`, and producer-supplied `occurredAt` domain time.
203
+ * producer-supplied `uuid`, producer-supplied `occurredAt` domain time, and its
204
+ * `metadata` bag.
116
205
  *
117
206
  * `data` is `Uint8Array` (opaque bytes); the store does not interpret it.
118
207
  * `uuid` is producer-supplied to keep `core` deterministic (no RNG in core).
208
+ * It is checked for UUID SHAPE only (`isGUID`), not for a version nibble: a
209
+ * stricter check would refuse ids an existing log already holds.
119
210
  * `occurredAt` is informational only; ordering is by position alone.
211
+ * `metadata` mirrors `tags` rather than `data`: REQUIRED, with an empty bag
212
+ * legal. Required because an absent-versus-empty divergence between engines is
213
+ * invisible to `toEqual`, which ignores `undefined` properties, so an optional
214
+ * field could drift across the three engines with the round-trip suite green.
215
+ * Core only carries it; the codec's `toEvent` is where it is resolved.
120
216
  */
121
217
  export const DcbEvent = Schema.Struct({
122
218
  type: EventType,
123
- data: Schema.Uint8ArrayFromSelf,
219
+ data: Schema.Uint8Array,
124
220
  tags: Schema.Array(Tag),
125
- uuid: Schema.UUID,
126
- occurredAt: Schema.DateTimeUtc,
221
+ uuid: Schema.String.check(Schema.isGUID()),
222
+ occurredAt: Schema.DateTimeUtcFromString,
223
+ metadata: Metadata,
127
224
  })
128
225
  export type DcbEvent = typeof DcbEvent.Type
129
226
 
package/src/Projection.ts CHANGED
@@ -38,7 +38,7 @@ const WILDCARD: Query = { items: [] }
38
38
 
39
39
  /**
40
40
  * An already-decoded event handed to a projection's fold: the sequenced wire
41
- * envelope (position, type, tags, timings) plus the decoded `payload`.
41
+ * envelope (position, type, tags, timings, metadata) plus the decoded `payload`.
42
42
  *
43
43
  * Core does not decode — it only folds events another layer has already decoded
44
44
  * — so `payload` is `unknown` at this layer. `@kairos-es/codec`'s builder emits
@@ -110,13 +110,24 @@ export type CompositeState<T extends Record<string, AnyProjection>> = {
110
110
  readonly [K in keyof T]: StateOf<T[K]>
111
111
  }
112
112
 
113
- /** Merge two queries by OR-ing their items; either wildcard yields a wildcard. */
114
- const mergeQuery = (left: Query, right: Query): Query => {
115
- if (left.items.length === 0 || right.items.length === 0) {
116
- return WILDCARD
117
- }
118
- return { items: [...left.items, ...right.items] }
119
- }
113
+ /**
114
+ * Merge two queries by OR-ing their items; either wildcard yields a wildcard.
115
+ *
116
+ * Every other key on either envelope is carried into the result. The grammar's
117
+ * envelope has no key but `items`, so anything else is a key the store boundary
118
+ * refuses (`servableQueryFault`); a merge that rebuilt the envelope from `items`
119
+ * alone would drop that key before the boundary saw it, and a slice composed with
120
+ * another would then have its smuggled predicate ignored in silence — the outcome
121
+ * the refusal exists to prevent.
122
+ */
123
+ const mergeQuery = (left: Query, right: Query): Query => ({
124
+ ...left,
125
+ ...right,
126
+ items:
127
+ left.items.length === 0 || right.items.length === 0
128
+ ? WILDCARD.items
129
+ : [...left.items, ...right.items],
130
+ })
120
131
 
121
132
  /**
122
133
  * OR-merge a list of queries into one. The empty list is the wildcard.
package/src/Query.ts CHANGED
@@ -79,6 +79,9 @@ export const matchesSequenced = (
79
79
  * (`Query.ts` module docs) but which plain-data assembly — e.g. decision-model
80
80
  * composition, `Projection.ts:31-36` — can still bypass.
81
81
  *
82
+ * A query, or any of its items, carrying a key the grammar does not define is
83
+ * refused before anything else is judged (`unknownKeyFault` below says why).
84
+ *
82
85
  * The servable grammar is exactly one of:
83
86
  * - the wildcard (`items.length === 0`), which matches every event;
84
87
  * - a *single* item (`items.length === 1`), any types/tags, including a
@@ -105,6 +108,72 @@ export const assertServableQuery = (query: Query): void => {
105
108
  }
106
109
  }
107
110
 
111
+ /**
112
+ * The refusal of a key `schemaFields` does not define, or `undefined` when `value`
113
+ * carries none. `where` names the level for the message.
114
+ *
115
+ * ## Why an unknown key is refused rather than ignored
116
+ *
117
+ * A `Query` is plain data, so nothing at runtime stops a caller adding a key the
118
+ * grammar does not have — a `metadata` predicate on an item is the one this repo
119
+ * expects, since metadata is the field an author most plausibly wants to filter on
120
+ * and must not (ADR-0017). Ignored, that key is a predicate the author believes they
121
+ * wrote and no engine honours, and on the append side it is worse: the Postgres
122
+ * guard embeds its match as literal plpgsql text that no type reaches, so a key a
123
+ * read path one day learned to honour would still be dropped by the guard, and the
124
+ * optimistic-concurrency check would escape in silence. Refusing every key the
125
+ * grammar does not define closes that route — a SMUGGLED key — at the one boundary
126
+ * every engine shares, before any engine could disagree. "Every key" means every
127
+ * key `Object.keys` returns: an own, enumerable, string key, which is what a plain
128
+ * object literal or a spread builds. A symbol key, a non-enumerable key or an
129
+ * inherited one is not judged, and no engine reads one either. Core's own query
130
+ * merge (`unionQueries`, `composeProjections`) carries an envelope's unknown key
131
+ * into the merged query rather than dropping it, so composing slices cannot launder
132
+ * a smuggled key past this check.
133
+ *
134
+ * It does not close the neighbouring one, and cannot: a field ADDED to the grammar's
135
+ * schema is a known key from that edit on and passes here, while the plpgsql guard
136
+ * still has to be taught it by hand. That exposure is the Postgres guard's, and its
137
+ * own comment in that engine's DDL says so.
138
+ *
139
+ * The known keys are read off the SCHEMA's own `fields` rather than listed here, so
140
+ * a field added to the grammar is admitted by the same edit that adds it. Only key
141
+ * NAMES are judged: an exact-optional key written as `undefined` is a known key and
142
+ * passes, because which values a key admits is the type's business, not this rule's.
143
+ */
144
+ const unknownKeyFault = (
145
+ where: string,
146
+ value: object,
147
+ schemaFields: object,
148
+ ): string | undefined => {
149
+ const unknown = Object.keys(value).find(
150
+ (key) => !Object.hasOwn(schemaFields, key),
151
+ )
152
+ return unknown === undefined
153
+ ? undefined
154
+ : `assertServableQuery: ${where} carries the key ${JSON.stringify(unknown)}, which ` +
155
+ 'the query grammar does not define; an unknown key would be silently ignored ' +
156
+ 'by every engine rather than honoured, so it is refused'
157
+ }
158
+
159
+ /**
160
+ * The unknown-key half of `servableQueryFault`, on its own: the refusal of a key the
161
+ * grammar does not define on the envelope or on any item, or `undefined`.
162
+ *
163
+ * It is the same rule and not a second statement of it — `servableQueryFault` calls
164
+ * this first. It is exported because ONE caller has to tell the two halves apart:
165
+ * the codec's read ledger refuses an unservable SHAPE on its own error channel, and
166
+ * must not report an unknown key as one. An unknown key is not something a slice's
167
+ * tags can fix, so the ledger leaves it to the store, which refuses it as a defect.
168
+ */
169
+ export const unknownQueryKeyFault = (query: Query): string | undefined =>
170
+ unknownKeyFault('a query', query, Query.fields) ??
171
+ query.items.reduce<string | undefined>(
172
+ (fault, item) =>
173
+ fault ?? unknownKeyFault('a query item', item, QueryItem.fields),
174
+ undefined,
175
+ )
176
+
108
177
  /**
109
178
  * The servable grammar as a TOTAL function: the reason `query` cannot be served,
110
179
  * or `undefined` when it can.
@@ -126,6 +195,10 @@ export const assertServableQuery = (query: Query): void => {
126
195
  * reason), so a fault is one exit route and one return type.
127
196
  */
128
197
  export const servableQueryFault = (query: Query): string | undefined => {
198
+ const unknownKey = unknownQueryKeyFault(query)
199
+ if (unknownKey !== undefined) {
200
+ return unknownKey
201
+ }
129
202
  const { items } = query
130
203
  // Wildcard and single-item queries are always servable.
131
204
  if (items.length <= 1) {
@@ -200,7 +273,7 @@ export const classifyServableQuery = (query: Query): ServableQueryShape => {
200
273
  }
201
274
  const only = query.items.length === 1 ? query.items[0] : undefined
202
275
  if (only !== undefined && only.tags.length === 0) {
203
- return Arr.isNonEmptyReadonlyArray(only.types)
276
+ return Arr.isReadonlyArrayNonEmpty(only.types)
204
277
  ? { kind: 'typeOnly', types: only.types }
205
278
  : { kind: 'wildcard' }
206
279
  }
@@ -69,7 +69,6 @@
69
69
  * where an ephemeral view re-catches-up the entire log on every boot.
70
70
  */
71
71
  import {
72
- Chunk,
73
72
  Effect,
74
73
  Option,
75
74
  Queue,
@@ -120,12 +119,12 @@ export const DEFAULT_POLL_INTERVAL_MILLIS = 1000
120
119
  * the key NAME, the VALIDATION and the DEFAULT from one place, so a caller who has
121
120
  * tuned one engine's subscribe reads another's without translating, and the two
122
121
  * cannot drift in what they ACCEPT any more than in what they assume. Two engines
123
- * hand-authoring the identical `Schema.optionalWith(…)` twice were identical only
122
+ * hand-authoring the identical `Schema.optionalKey(…)` twice were identical only
124
123
  * by inspection.
125
124
  *
126
125
  * The bounds differ between the two because the quantities do. `pollInterval` is a
127
- * duration, so a fraction of a millisecond is meaningful and only `positive` and
128
- * `finite` matter: at zero or below the loop would spin without ever waiting for a
126
+ * duration, so a fraction of a millisecond is meaningful and only `isGreaterThan(0)`
127
+ * and `isFinite` matter: at zero or below the loop would spin without ever waiting for a
129
128
  * wake, and at `Infinity` the periodic re-read — the half that carries delivery —
130
129
  * would never come, which silently converts the wake source from an optimisation
131
130
  * into the guarantee. `catchUpPageSize` is a COUNT, so it must be an integer
@@ -133,18 +132,17 @@ export const DEFAULT_POLL_INTERVAL_MILLIS = 1000
133
132
  * length against it to decide the tail is drained, and a page of zero would report
134
133
  * "drained" forever without reading anything.
135
134
  *
136
- * `exact: true` on both keeps the decoded type at `key?: T` rather than
135
+ * `optionalKey` on both keeps the decoded type at `key?: T` rather than
137
136
  * `T | undefined`, matching the repo's `exactOptionalPropertyTypes` discipline — an
138
137
  * absent key, never an explicit `undefined`.
139
138
  */
140
139
  export const subscriptionTuningFields = {
141
- pollInterval: Schema.optionalWith(
142
- Schema.Number.pipe(Schema.positive(), Schema.finite()),
143
- { exact: true },
140
+ pollInterval: Schema.optionalKey(
141
+ Schema.Number.check(Schema.isGreaterThan(0), Schema.isFinite()),
142
+ ),
143
+ catchUpPageSize: Schema.optionalKey(
144
+ Schema.Int.check(Schema.isGreaterThan(0)),
144
145
  ),
145
- catchUpPageSize: Schema.optionalWith(Schema.Int.pipe(Schema.positive()), {
146
- exact: true,
147
- }),
148
146
  } as const
149
147
 
150
148
  /**
@@ -154,10 +152,13 @@ export const subscriptionTuningFields = {
154
152
  * past the cap propagates (the decorator dies it) — infrastructure stays off the
155
153
  * contract channel while the stream survives ordinary transients.
156
154
  */
157
- const POLL_RETRY_SCHEDULE = Schedule.exponential('100 millis', 2).pipe(
158
- Schedule.union(Schedule.spaced('2 seconds')),
159
- Schedule.intersect(Schedule.recurs(20)),
160
- )
155
+ const POLL_RETRY_SCHEDULE = Schedule.max([
156
+ Schedule.min([
157
+ Schedule.exponential('100 millis', 2),
158
+ Schedule.spaced('2 seconds'),
159
+ ]),
160
+ Schedule.recurs(20),
161
+ ])
161
162
 
162
163
  /**
163
164
  * The two seams the subscribe machine runs over, plus the two tuning knobs. `E` is
@@ -166,7 +167,7 @@ const POLL_RETRY_SCHEDULE = Schedule.exponential('100 millis', 2).pipe(
166
167
  *
167
168
  * ONE type parameter and not two, deliberately: a wake source with an error type of
168
169
  * its own is `Stream.orDie`d by the caller, yielding a `Stream<unknown, never>`
169
- * that is assignable here under Effect v3's covariant error channel. A second
170
+ * that is assignable here under Effect's covariant error channel. A second
170
171
  * parameter would buy nothing and would have to be spelled at every call site. Nor
171
172
  * is there an `R`: neither seam contributes a requirement, and unused generality is
172
173
  * not free.
@@ -199,17 +200,17 @@ export interface SubscriptionDeps<E> {
199
200
 
200
201
  /**
201
202
  * Build the subscription `Stream`: catch up from `after`, then track the live tail.
202
- * The cursor threads through the `unfoldChunkEffect` unfold state, starting at
203
- * `after` and advancing to the last delivered position each batch; the stream never
204
- * completes on its own (always `Option.some`), so the enclosing scope interrupts it
205
- * on close. An `E` mid-stream stays on the channel; `makeContractStore` dies it
203
+ * The cursor threads through the `Stream.paginate` state, starting at `after` and
204
+ * advancing to the last delivered position each batch; the stream never completes
205
+ * on its own (the next state is always `Option.some`), so the enclosing scope
206
+ * interrupts it on close. An `E` mid-stream stays on the channel; `makeContractStore` dies it
206
207
  * (subscribe is `E = never` on the contract).
207
208
  */
208
209
  export const subscribeStream = <E>(
209
210
  deps: SubscriptionDeps<E>,
210
211
  after: Position | undefined,
211
212
  ): Stream.Stream<SequencedEvent, E, Scope.Scope> =>
212
- Stream.unwrapScoped(
213
+ Stream.unwrap(
213
214
  Effect.gen(function* () {
214
215
  // The wake primitive: a sliding(1) queue holding at most one pending
215
216
  // signal. The wake source `offer`s an opaque wake on every notification;
@@ -230,9 +231,10 @@ export const subscribeStream = <E>(
230
231
  const listener = (deps.listen ?? Stream.never).pipe(
231
232
  Stream.tap(() => Queue.offer(wake, undefined)),
232
233
  Stream.retry(
233
- Schedule.exponential('50 millis').pipe(
234
- Schedule.union(Schedule.spaced('1 seconds')),
235
- ),
234
+ Schedule.min([
235
+ Schedule.exponential('50 millis'),
236
+ Schedule.spaced('1 seconds'),
237
+ ]),
236
238
  ),
237
239
  )
238
240
  // Drain the wake source into the wake queue on a background fibre; the
@@ -280,7 +282,7 @@ export const subscribeStream = <E>(
280
282
 
281
283
  /**
282
284
  * Produce the NEXT non-empty batch from a cursor position, together with
283
- * the advanced cursor — a pure `Position → Effect<[Chunk, next]>`. The
285
+ * the advanced cursor — a pure `Position → Effect<[events, next]>`. The
284
286
  * `> boundary` de-dup is enforced by `fetch`'s exclusive lower bound, so a
285
287
  * live re-read after a wake never re-emits an already-delivered position.
286
288
  *
@@ -288,18 +290,20 @@ export const subscribeStream = <E>(
288
290
  * the cursor. If a drain yields nothing, wait for a wake but only up to the
289
291
  * poll interval, then loop and re-read ANYWAY — the periodic position-based
290
292
  * poll that makes delivery correct independently of any wake. This effect
291
- * only completes by returning a non-empty chunk; the scope interrupts it on
293
+ * only completes by returning a non-empty batch; the scope interrupts it on
292
294
  * close.
293
295
  */
294
296
  const nextBatch = (
295
297
  from: Position,
296
- ): Effect.Effect<readonly [Chunk.Chunk<SequencedEvent>, Position], E> =>
298
+ ): Effect.Effect<readonly [ReadonlyArray<SequencedEvent>, Position], E> =>
297
299
  Effect.gen(function* () {
298
300
  while (true) {
299
- yield* Queue.takeAll(wake)
301
+ // `Queue.clear` and not `Queue.takeAll`: the drain must NOT wait, and
302
+ // `takeAll` suspends until at least one signal is present.
303
+ yield* Queue.clear(wake)
300
304
  const { events, last } = yield* drainFrom(from)
301
305
  if (events.length > 0) {
302
- return [Chunk.fromIterable(events), last] as const
306
+ return [events, last] as const
303
307
  }
304
308
  // Nothing new: block on a wake, but only up to the poll interval — on
305
309
  // timeout we fall through and the loop re-reads regardless.
@@ -309,8 +313,11 @@ export const subscribeStream = <E>(
309
313
  }
310
314
  })
311
315
 
312
- return Stream.unfoldChunkEffect(after ?? ORIGIN, (from) =>
313
- Effect.map(nextBatch(from), Option.some),
316
+ return Stream.paginate(after ?? ORIGIN, (from) =>
317
+ Effect.map(
318
+ nextBatch(from),
319
+ ([events, last]) => [events, Option.some(last)] as const,
320
+ ),
314
321
  )
315
322
  }),
316
323
  )