@kairos-es/feed 0.0.0-stage → 0.0.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 (132) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +109 -2
  3. package/dist/cjs/Feed.js +6 -0
  4. package/dist/cjs/Feed.js.map +1 -0
  5. package/dist/cjs/FeedSink.js +54 -0
  6. package/dist/cjs/FeedSink.js.map +1 -0
  7. package/dist/cjs/FeedSyncStore.js +10 -0
  8. package/dist/cjs/FeedSyncStore.js.map +1 -0
  9. package/dist/cjs/deliver.js +86 -0
  10. package/dist/cjs/deliver.js.map +1 -0
  11. package/dist/cjs/errors.js +114 -0
  12. package/dist/cjs/errors.js.map +1 -0
  13. package/dist/cjs/identifiers.js +63 -0
  14. package/dist/cjs/identifiers.js.map +1 -0
  15. package/dist/cjs/inMemoryFeed.js +328 -0
  16. package/dist/cjs/inMemoryFeed.js.map +1 -0
  17. package/dist/cjs/inMemorySink.js +142 -0
  18. package/dist/cjs/inMemorySink.js.map +1 -0
  19. package/dist/cjs/index.js +184 -0
  20. package/dist/cjs/index.js.map +1 -0
  21. package/dist/cjs/inspect.js +68 -0
  22. package/dist/cjs/inspect.js.map +1 -0
  23. package/dist/cjs/readiness.js +100 -0
  24. package/dist/cjs/readiness.js.map +1 -0
  25. package/dist/cjs/reconcile.js +164 -0
  26. package/dist/cjs/reconcile.js.map +1 -0
  27. package/dist/cjs/runFeedSync.js +603 -0
  28. package/dist/cjs/runFeedSync.js.map +1 -0
  29. package/dist/cjs/tuning.js +60 -0
  30. package/dist/cjs/tuning.js.map +1 -0
  31. package/dist/dts-cjs/Feed.d.ts +99 -0
  32. package/dist/dts-cjs/Feed.d.ts.map +1 -0
  33. package/dist/dts-cjs/FeedSink.d.ts +189 -0
  34. package/dist/dts-cjs/FeedSink.d.ts.map +1 -0
  35. package/dist/dts-cjs/FeedSyncStore.d.ts +281 -0
  36. package/dist/dts-cjs/FeedSyncStore.d.ts.map +1 -0
  37. package/dist/dts-cjs/deliver.d.ts +16 -0
  38. package/dist/dts-cjs/deliver.d.ts.map +1 -0
  39. package/dist/dts-cjs/errors.d.ts +131 -0
  40. package/dist/dts-cjs/errors.d.ts.map +1 -0
  41. package/dist/dts-cjs/identifiers.d.ts +67 -0
  42. package/dist/dts-cjs/identifiers.d.ts.map +1 -0
  43. package/dist/dts-cjs/inMemoryFeed.d.ts +32 -0
  44. package/dist/dts-cjs/inMemoryFeed.d.ts.map +1 -0
  45. package/dist/dts-cjs/inMemorySink.d.ts +61 -0
  46. package/dist/dts-cjs/inMemorySink.d.ts.map +1 -0
  47. package/dist/dts-cjs/index.d.ts +63 -0
  48. package/dist/dts-cjs/index.d.ts.map +1 -0
  49. package/dist/dts-cjs/inspect.d.ts +71 -0
  50. package/dist/dts-cjs/inspect.d.ts.map +1 -0
  51. package/dist/dts-cjs/package.json +3 -0
  52. package/dist/dts-cjs/readiness.d.ts +84 -0
  53. package/dist/dts-cjs/readiness.d.ts.map +1 -0
  54. package/dist/dts-cjs/reconcile.d.ts +68 -0
  55. package/dist/dts-cjs/reconcile.d.ts.map +1 -0
  56. package/dist/dts-cjs/runFeedSync.d.ts +220 -0
  57. package/dist/dts-cjs/runFeedSync.d.ts.map +1 -0
  58. package/dist/dts-cjs/tuning.d.ts +40 -0
  59. package/dist/dts-cjs/tuning.d.ts.map +1 -0
  60. package/dist/dts-esm/Feed.d.ts +99 -0
  61. package/dist/dts-esm/Feed.d.ts.map +1 -0
  62. package/dist/dts-esm/FeedSink.d.ts +189 -0
  63. package/dist/dts-esm/FeedSink.d.ts.map +1 -0
  64. package/dist/dts-esm/FeedSyncStore.d.ts +281 -0
  65. package/dist/dts-esm/FeedSyncStore.d.ts.map +1 -0
  66. package/dist/dts-esm/deliver.d.ts +16 -0
  67. package/dist/dts-esm/deliver.d.ts.map +1 -0
  68. package/dist/dts-esm/errors.d.ts +131 -0
  69. package/dist/dts-esm/errors.d.ts.map +1 -0
  70. package/dist/dts-esm/identifiers.d.ts +67 -0
  71. package/dist/dts-esm/identifiers.d.ts.map +1 -0
  72. package/dist/dts-esm/inMemoryFeed.d.ts +32 -0
  73. package/dist/dts-esm/inMemoryFeed.d.ts.map +1 -0
  74. package/dist/dts-esm/inMemorySink.d.ts +61 -0
  75. package/dist/dts-esm/inMemorySink.d.ts.map +1 -0
  76. package/dist/dts-esm/index.d.ts +63 -0
  77. package/dist/dts-esm/index.d.ts.map +1 -0
  78. package/dist/dts-esm/inspect.d.ts +71 -0
  79. package/dist/dts-esm/inspect.d.ts.map +1 -0
  80. package/dist/dts-esm/package.json +3 -0
  81. package/dist/dts-esm/readiness.d.ts +84 -0
  82. package/dist/dts-esm/readiness.d.ts.map +1 -0
  83. package/dist/dts-esm/reconcile.d.ts +68 -0
  84. package/dist/dts-esm/reconcile.d.ts.map +1 -0
  85. package/dist/dts-esm/runFeedSync.d.ts +220 -0
  86. package/dist/dts-esm/runFeedSync.d.ts.map +1 -0
  87. package/dist/dts-esm/tuning.d.ts +40 -0
  88. package/dist/dts-esm/tuning.d.ts.map +1 -0
  89. package/dist/esm/Feed.js +2 -0
  90. package/dist/esm/Feed.js.map +1 -0
  91. package/dist/esm/FeedSink.js +46 -0
  92. package/dist/esm/FeedSink.js.map +1 -0
  93. package/dist/esm/FeedSyncStore.js +3 -0
  94. package/dist/esm/FeedSyncStore.js.map +1 -0
  95. package/dist/esm/deliver.js +78 -0
  96. package/dist/esm/deliver.js.map +1 -0
  97. package/dist/esm/errors.js +98 -0
  98. package/dist/esm/errors.js.map +1 -0
  99. package/dist/esm/identifiers.js +56 -0
  100. package/dist/esm/identifiers.js.map +1 -0
  101. package/dist/esm/inMemoryFeed.js +320 -0
  102. package/dist/esm/inMemoryFeed.js.map +1 -0
  103. package/dist/esm/inMemorySink.js +134 -0
  104. package/dist/esm/inMemorySink.js.map +1 -0
  105. package/dist/esm/index.js +62 -0
  106. package/dist/esm/index.js.map +1 -0
  107. package/dist/esm/inspect.js +58 -0
  108. package/dist/esm/inspect.js.map +1 -0
  109. package/dist/esm/package.json +4 -0
  110. package/dist/esm/readiness.js +92 -0
  111. package/dist/esm/readiness.js.map +1 -0
  112. package/dist/esm/reconcile.js +153 -0
  113. package/dist/esm/reconcile.js.map +1 -0
  114. package/dist/esm/runFeedSync.js +595 -0
  115. package/dist/esm/runFeedSync.js.map +1 -0
  116. package/dist/esm/tuning.js +51 -0
  117. package/dist/esm/tuning.js.map +1 -0
  118. package/package.json +34 -3
  119. package/src/Feed.ts +111 -0
  120. package/src/FeedSink.ts +130 -0
  121. package/src/FeedSyncStore.ts +326 -0
  122. package/src/deliver.ts +108 -0
  123. package/src/errors.ts +134 -0
  124. package/src/identifiers.ts +92 -0
  125. package/src/inMemoryFeed.ts +582 -0
  126. package/src/inMemorySink.ts +215 -0
  127. package/src/index.ts +136 -0
  128. package/src/inspect.ts +114 -0
  129. package/src/readiness.ts +137 -0
  130. package/src/reconcile.ts +224 -0
  131. package/src/runFeedSync.ts +882 -0
  132. package/src/tuning.ts +65 -0
package/src/deliver.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * One batch to a sink, end to end: send, park what the sink refuses, send the rest
3
+ * again, then confirm. Package-internal, and the ONE place the driver talks to a
4
+ * sink's write path, so the steady state, a resync's load and a reconcile's
5
+ * deletes cannot each grow their own idea of what a refusal means.
6
+ *
7
+ * ## Why a refusal re-sends the rest rather than skipping the batch
8
+ *
9
+ * A sink that refuses one record of a batch has, by the port's contract, applied
10
+ * none of it — Algolia, the first sink, rejects a whole `batch` call for one
11
+ * oversized record. So the records it did NOT name must go again, without the
12
+ * refused ones, until the sink takes what is left or there is nothing left. Each
13
+ * round removes at least one key, so the loop ends. A sink that names no key of
14
+ * the batch would loop forever instead, and that is a sink bug the driver turns
15
+ * into `SinkHalted` rather than spin on.
16
+ *
17
+ * ## The lease, before every send
18
+ *
19
+ * `ensureHeld` runs before each `send`, not once per batch: a refusal round is a
20
+ * send like any other, and a worker that lost its lease between two rounds must
21
+ * not make the second.
22
+ */
23
+ import { Array as Arr, DateTime, Effect } from 'effect'
24
+ import type { NonEmptyReadonlyArray } from 'effect/Array'
25
+ import {
26
+ type FeedUnreadable,
27
+ type LeaseLost,
28
+ SinkHalted,
29
+ type SinkRejected,
30
+ type SinkRetryable,
31
+ } from './errors'
32
+ import type { FeedChange } from './FeedSink'
33
+ import type { FeedLease, ParkedRecord } from './FeedSyncStore'
34
+ import type { FeedKey, FeedSeq } from './identifiers'
35
+
36
+ /** One change to send, and the `seq` of the row it came from (for its park). */
37
+ export interface Pending<D> {
38
+ readonly change: FeedChange<D>
39
+ readonly seq: FeedSeq
40
+ }
41
+
42
+ /** What a delivered batch came to: the keys the sink took, and the parks. */
43
+ export interface Delivered {
44
+ readonly sent: ReadonlyArray<FeedKey>
45
+ readonly parked: ReadonlyArray<ParkedRecord>
46
+ }
47
+
48
+ /** The reason recorded for a refused key the sink gave no reason for. */
49
+ export const UNEXPLAINED_REFUSAL = 'refused by the sink, with no reason given'
50
+
51
+ /**
52
+ * Send `pending` through `send`, parking each refused key with the sink's reason,
53
+ * and confirm the receipt of whatever was finally taken. See the module doc.
54
+ *
55
+ * @internal
56
+ */
57
+ export const deliver = <D, Receipt>(args: {
58
+ readonly send: (
59
+ batch: NonEmptyReadonlyArray<FeedChange<D>>,
60
+ ) => Effect.Effect<Receipt, SinkRetryable | SinkRejected | SinkHalted>
61
+ readonly confirm: (
62
+ receipt: Receipt,
63
+ ) => Effect.Effect<void, SinkRetryable | SinkHalted>
64
+ readonly lease: FeedLease
65
+ readonly pending: NonEmptyReadonlyArray<Pending<D>>
66
+ }): Effect.Effect<
67
+ Delivered,
68
+ SinkRetryable | SinkHalted | LeaseLost | FeedUnreadable
69
+ > =>
70
+ Effect.gen(function* () {
71
+ let remaining: ReadonlyArray<Pending<D>> = args.pending
72
+ const parked: Array<ParkedRecord> = []
73
+ for (;;) {
74
+ if (!Arr.isReadonlyArrayNonEmpty(remaining)) {
75
+ return { sent: [], parked }
76
+ }
77
+ const batch = Arr.map(remaining, (p) => p.change)
78
+ yield* args.lease.ensureHeld
79
+ const sent = yield* args.send(batch).pipe(
80
+ Effect.map((receipt) => ({ _tag: 'Taken' as const, receipt })),
81
+ Effect.catchTag('SinkRejected', (rejected) =>
82
+ Effect.succeed({ _tag: 'Refused' as const, rejected }),
83
+ ),
84
+ )
85
+ if (sent._tag === 'Taken') {
86
+ yield* args.confirm(sent.receipt)
87
+ return { sent: remaining.map((p) => p.change.key), parked }
88
+ }
89
+ const refused = new Set<FeedKey>(sent.rejected.keys)
90
+ const named = remaining.filter((p) => refused.has(p.change.key))
91
+ if (named.length === 0) {
92
+ return yield* new SinkHalted({
93
+ reason: `the sink refused keys that are not in the batch (${sent.rejected.keys.join(', ')})`,
94
+ })
95
+ }
96
+ const parkedAt = yield* DateTime.now
97
+ for (const p of named) {
98
+ parked.push({
99
+ key: p.change.key,
100
+ seq: p.seq,
101
+ reason:
102
+ sent.rejected.reasons.get(p.change.key) ?? UNEXPLAINED_REFUSAL,
103
+ parkedAt,
104
+ })
105
+ }
106
+ remaining = remaining.filter((p) => !refused.has(p.change.key))
107
+ }
108
+ })
package/src/errors.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The change feed's error vocabulary.
3
+ *
4
+ * ## The sink's three answers
5
+ *
6
+ * A sink classifies every failure into one of three, and the driver's whole
7
+ * behaviour on failure follows from which. The names borrow the meaning of
8
+ * `@kairos-es/automation`'s classification without depending on that package: a
9
+ * feed is not an automation (ADR-0019), and a dependency for three class names
10
+ * would make it look like one.
11
+ *
12
+ * - `SinkRetryable` — the sink could not take the batch NOW (a rate limit, a 5xx, a
13
+ * timeout). The cursor stays where it is and the batch is sent again after a
14
+ * back-off, never sooner than `retryAfter` when the sink names one.
15
+ * - `SinkRejected` — the sink will never take THESE records as they stand (too
16
+ * large, invalid). The named keys are parked with the sink's reasons, the rest of
17
+ * the batch is sent again without them, and the cursor moves past the whole page.
18
+ * A parked record is retried when its row next changes.
19
+ * - `SinkHalted` — something is wrong that retrying will not mend (bad
20
+ * credentials, a missing index). The worker stops, and supervision decides.
21
+ *
22
+ * ## The feed's own tables
23
+ *
24
+ * `FeedWriteFailed` and `FeedUnreadable` are infrastructure faults on the feed's
25
+ * own durable state, split by direction: a write that did not land, a read that
26
+ * could not be made. They are tagged errors rather than defects for the reason the
27
+ * `ProjectionStore` port gives for its own: the driver's supervision has to tell
28
+ * "back off and try again" from "give up", and only a value can carry that.
29
+ *
30
+ * ## The worker's lease and cursor
31
+ *
32
+ * `LeaseHeld`, `LeaseLost` and `CursorSuperseded` are the three ways the
33
+ * single-worker guarantee shows itself: another worker holds the lease, this
34
+ * worker's lease went while it ran, or the cursor it expected to advance is not
35
+ * the one stored (a rival moved it, or the lease changed hands).
36
+ */
37
+
38
+ import { Data, Duration } from 'effect'
39
+ import type { NonEmptyReadonlyArray } from 'effect/Array'
40
+ import type { FeedKey, FeedName, FeedSinkId, SinkName } from './identifiers'
41
+
42
+ /** The sink cannot take the batch now: keep the cursor and back off. */
43
+ export class SinkRetryable extends Data.TaggedError('SinkRetryable')<{
44
+ readonly reason: string
45
+ /** The least the driver waits before sending again, when the sink knows it. */
46
+ readonly retryAfter?: Duration.Duration
47
+ }> {
48
+ override get message(): string {
49
+ return this.retryAfter === undefined
50
+ ? this.reason
51
+ : `${this.reason} (retry after ${Duration.format(this.retryAfter)})`
52
+ }
53
+ }
54
+
55
+ /** The sink refuses these records as they stand: park them, send the rest. */
56
+ export class SinkRejected extends Data.TaggedError('SinkRejected')<{
57
+ readonly keys: NonEmptyReadonlyArray<FeedKey>
58
+ /** The sink's reason per refused key; a key absent here is parked unexplained. */
59
+ readonly reasons: ReadonlyMap<FeedKey, string>
60
+ }> {
61
+ override get message(): string {
62
+ return `the sink refused ${this.keys.length} record(s): ${this.keys.join(', ')}`
63
+ }
64
+ }
65
+
66
+ /** Something retrying will not mend: stop the worker. */
67
+ export class SinkHalted extends Data.TaggedError('SinkHalted')<{
68
+ readonly reason: string
69
+ }> {
70
+ override get message(): string {
71
+ return this.reason
72
+ }
73
+ }
74
+
75
+ /** A write to the feed's own tables did not land. */
76
+ export class FeedWriteFailed extends Data.TaggedError('FeedWriteFailed')<{
77
+ readonly reason: string
78
+ readonly cause?: unknown
79
+ }> {
80
+ override get message(): string {
81
+ return this.reason
82
+ }
83
+ }
84
+
85
+ /** A read of the feed's own tables could not be made. */
86
+ export class FeedUnreadable extends Data.TaggedError('FeedUnreadable')<{
87
+ readonly reason: string
88
+ readonly cause?: unknown
89
+ }> {
90
+ override get message(): string {
91
+ return this.reason
92
+ }
93
+ }
94
+
95
+ /** Another worker holds the lease for this feed and sink. */
96
+ export class LeaseHeld extends Data.TaggedError('LeaseHeld')<FeedSinkId> {
97
+ override get message(): string {
98
+ return `another worker holds the lease for feed '${this.feed}' and sink '${this.sink}'`
99
+ }
100
+ }
101
+
102
+ /** This worker's lease is no longer its own; it must stop sending at once. */
103
+ export class LeaseLost extends Data.TaggedError('LeaseLost')<FeedSinkId> {
104
+ override get message(): string {
105
+ return `the lease for feed '${this.feed}' and sink '${this.sink}' was lost`
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The cursor compare-and-set lost: the stored cursor is not the one expected, or
111
+ * the lease is not held by the worker that asked. Nothing the call carried landed.
112
+ */
113
+ export class CursorSuperseded extends Data.TaggedError(
114
+ 'CursorSuperseded',
115
+ )<FeedSinkId> {
116
+ override get message(): string {
117
+ return `the cursor for feed '${this.feed}' and sink '${this.sink}' was superseded`
118
+ }
119
+ }
120
+
121
+ /**
122
+ * The sync worker gave up: it restarted `restarts` times in a row without moving
123
+ * its cursor once. `reason` is the failure that ended the last attempt.
124
+ */
125
+ export class FeedSyncStalled extends Data.TaggedError('FeedSyncStalled')<{
126
+ readonly feed: FeedName
127
+ readonly sink: SinkName
128
+ readonly restarts: number
129
+ readonly reason: unknown
130
+ }> {
131
+ override get message(): string {
132
+ return `the sync of feed '${this.feed}' to sink '${this.sink}' made no progress in ${this.restarts} consecutive restarts`
133
+ }
134
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The change feed's branded identifiers.
3
+ *
4
+ * Every one of them is a branded `Schema`, as every domain identifier in this
5
+ * repository is, so a raw string or `bigint` cannot stand in for one by accident.
6
+ * Two of them are worth a word, because they look like things the read side
7
+ * already has and are deliberately not those things.
8
+ *
9
+ * - `FeedSeq` is NOT a `Position`. A `Position` is a place in the global event log,
10
+ * and one event may fan out into many feed rows (a teacher renamed, fifty courses
11
+ * touched). Give those fifty rows the event's position as their cursor and they
12
+ * all share one value, so a page boundary that falls among them loses the rest of
13
+ * them: the next page asks for rows strictly after a value the lost ones also
14
+ * carry. A feed-local sequence gives every row a cursor value of its own. The
15
+ * event position is still kept on each row, as `eventPosition`, for the one
16
+ * question it answers — "is the sink at least as fresh as P?" — and is never a
17
+ * cursor.
18
+ * - `Generation` is NOT a counter a caller reads meaning into. It is compared for
19
+ * EQUALITY only: a sink whose cursor was saved at a generation other than the
20
+ * feed's current one resyncs. That is what lets the in-memory feed draw a fresh
21
+ * generation from `Random` on every boot while a durable feed counts upwards, and
22
+ * both satisfy the one rule the driver needs.
23
+ */
24
+ import { Schema } from 'effect'
25
+
26
+ /**
27
+ * The name of one change feed. A feed is bound to ONE materialisation, because a
28
+ * feed sequence taken inside a commit is ascending in commit order only while one
29
+ * transaction at a time writes that feed — which the read side gives per
30
+ * materialisation and no wider.
31
+ */
32
+ export const FeedName = Schema.String.pipe(
33
+ Schema.check(Schema.isNonEmpty()),
34
+ Schema.brand('FeedName'),
35
+ )
36
+ export type FeedName = typeof FeedName.Type
37
+
38
+ /** A record's key within a feed, and the sink's object id for that record. */
39
+ export const FeedKey = Schema.String.pipe(
40
+ Schema.check(Schema.isNonEmpty()),
41
+ Schema.brand('FeedKey'),
42
+ )
43
+ export type FeedKey = typeof FeedKey.Type
44
+
45
+ /**
46
+ * The feed-local cursor: unique, and ascending in commit order. Compared, never
47
+ * arithmetic'd, and never assumed contiguous — a sequence shared by several feeds
48
+ * leaves gaps in each, and a coalesced row gives up its old value when it moves.
49
+ */
50
+ export const FeedSeq = Schema.BigInt.pipe(
51
+ Schema.check(Schema.isGreaterThanOrEqualToBigInt(0n)),
52
+ Schema.brand('FeedSeq'),
53
+ )
54
+ export type FeedSeq = typeof FeedSeq.Type
55
+
56
+ /** The sentinel feed sequence meaning "before the first row". */
57
+ export const FEED_ORIGIN: FeedSeq = FeedSeq.make(0n)
58
+
59
+ /**
60
+ * The feed's generation: it moves on every reset of the materialisation the feed
61
+ * is bound to, and on every boot of an ephemeral feed. Compared for equality only;
62
+ * see the module doc.
63
+ */
64
+ export const Generation = Schema.BigInt.pipe(
65
+ Schema.check(Schema.isGreaterThanOrEqualToBigInt(0n)),
66
+ Schema.brand('Generation'),
67
+ )
68
+ export type Generation = typeof Generation.Type
69
+
70
+ /** The name of one sink. A feed keeps one cursor per sink. */
71
+ export const SinkName = Schema.String.pipe(
72
+ Schema.check(Schema.isNonEmpty()),
73
+ Schema.brand('SinkName'),
74
+ )
75
+ export type SinkName = typeof SinkName.Type
76
+
77
+ /**
78
+ * Who holds a sync worker's lease. Issued by the `FeedSyncStore` when the lease is
79
+ * taken, and checked again by every cursor compare-and-set, so a worker that has
80
+ * lost its lease cannot move the cursor even if it is still running.
81
+ */
82
+ export const LeaseHolder = Schema.String.pipe(
83
+ Schema.check(Schema.isNonEmpty()),
84
+ Schema.brand('LeaseHolder'),
85
+ )
86
+ export type LeaseHolder = typeof LeaseHolder.Type
87
+
88
+ /** One sync worker's identity: the feed it reads and the sink it writes. */
89
+ export interface FeedSinkId {
90
+ readonly feed: FeedName
91
+ readonly sink: SinkName
92
+ }