@kairos-es/feed-postgres 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.
- package/LICENSE +28 -0
- package/README.md +84 -2
- package/dist/cjs/config.js +46 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureFeedSchema.js +39 -0
- package/dist/cjs/ensureFeedSchema.js.map +1 -0
- package/dist/cjs/feed.js +176 -0
- package/dist/cjs/feed.js.map +1 -0
- package/dist/cjs/index.js +84 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/context.js +45 -0
- package/dist/cjs/internal/context.js.map +1 -0
- package/dist/cjs/internal/ddl.js +149 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/statements.js +270 -0
- package/dist/cjs/internal/statements.js.map +1 -0
- package/dist/cjs/lease.js +342 -0
- package/dist/cjs/lease.js.map +1 -0
- package/dist/cjs/syncStore.js +179 -0
- package/dist/cjs/syncStore.js.map +1 -0
- package/dist/cjs/withFeed.js +51 -0
- package/dist/cjs/withFeed.js.map +1 -0
- package/dist/dts-cjs/config.d.ts +37 -0
- package/dist/dts-cjs/config.d.ts.map +1 -0
- package/dist/dts-cjs/ensureFeedSchema.d.ts +24 -0
- package/dist/dts-cjs/ensureFeedSchema.d.ts.map +1 -0
- package/dist/dts-cjs/feed.d.ts +81 -0
- package/dist/dts-cjs/feed.d.ts.map +1 -0
- package/dist/dts-cjs/index.d.ts +50 -0
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/dts-cjs/internal/context.d.ts +28 -0
- package/dist/dts-cjs/internal/context.d.ts.map +1 -0
- package/dist/dts-cjs/internal/ddl.d.ts +99 -0
- package/dist/dts-cjs/internal/ddl.d.ts.map +1 -0
- package/dist/dts-cjs/internal/statements.d.ts +127 -0
- package/dist/dts-cjs/internal/statements.d.ts.map +1 -0
- package/dist/dts-cjs/lease.d.ts +138 -0
- package/dist/dts-cjs/lease.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/syncStore.d.ts +57 -0
- package/dist/dts-cjs/syncStore.d.ts.map +1 -0
- package/dist/dts-cjs/withFeed.d.ts +69 -0
- package/dist/dts-cjs/withFeed.d.ts.map +1 -0
- package/dist/dts-esm/config.d.ts +37 -0
- package/dist/dts-esm/config.d.ts.map +1 -0
- package/dist/dts-esm/ensureFeedSchema.d.ts +24 -0
- package/dist/dts-esm/ensureFeedSchema.d.ts.map +1 -0
- package/dist/dts-esm/feed.d.ts +81 -0
- package/dist/dts-esm/feed.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +50 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/internal/context.d.ts +28 -0
- package/dist/dts-esm/internal/context.d.ts.map +1 -0
- package/dist/dts-esm/internal/ddl.d.ts +99 -0
- package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
- package/dist/dts-esm/internal/statements.d.ts +127 -0
- package/dist/dts-esm/internal/statements.d.ts.map +1 -0
- package/dist/dts-esm/lease.d.ts +138 -0
- package/dist/dts-esm/lease.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/syncStore.d.ts +57 -0
- package/dist/dts-esm/syncStore.d.ts.map +1 -0
- package/dist/dts-esm/withFeed.d.ts +69 -0
- package/dist/dts-esm/withFeed.d.ts.map +1 -0
- package/dist/esm/config.js +39 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureFeedSchema.js +31 -0
- package/dist/esm/ensureFeedSchema.js.map +1 -0
- package/dist/esm/feed.js +166 -0
- package/dist/esm/feed.js.map +1 -0
- package/dist/esm/index.js +50 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/context.js +34 -0
- package/dist/esm/internal/context.js.map +1 -0
- package/dist/esm/internal/ddl.js +136 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/statements.js +262 -0
- package/dist/esm/internal/statements.js.map +1 -0
- package/dist/esm/lease.js +330 -0
- package/dist/esm/lease.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/syncStore.js +171 -0
- package/dist/esm/syncStore.js.map +1 -0
- package/dist/esm/withFeed.js +44 -0
- package/dist/esm/withFeed.js.map +1 -0
- package/package.json +35 -3
- package/src/config.ts +46 -0
- package/src/ensureFeedSchema.ts +32 -0
- package/src/feed.ts +284 -0
- package/src/index.ts +67 -0
- package/src/internal/context.ts +44 -0
- package/src/internal/ddl.ts +233 -0
- package/src/internal/statements.ts +387 -0
- package/src/lease.ts +503 -0
- package/src/syncStore.ts +340 -0
- package/src/withFeed.ts +148 -0
package/src/syncStore.ts
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Postgres `FeedSyncStore`: every sink's cursor, its parked list and its
|
|
3
|
+
* worker lease, kept in the source database beside the feed rows (ADR-0019 §10).
|
|
4
|
+
*
|
|
5
|
+
* `@kairos-es/feed`'s `FeedSyncStore.ts` owns the contract, and the in-memory feed
|
|
6
|
+
* is the oracle this store is held to; what is here is how each atomic step the
|
|
7
|
+
* contract names becomes one transaction or one statement.
|
|
8
|
+
*
|
|
9
|
+
* - `casCursor` is ONE transaction: the guarded, holder-fenced cursor update,
|
|
10
|
+
* stamped with its `lastAdvance` from this process's `Clock`, and the parked
|
|
11
|
+
* list moved with it. A guard that loses fails `CursorSuperseded` inside the
|
|
12
|
+
* transaction, which rolls back the parked changes with it, so a park can never
|
|
13
|
+
* outlive a refused advance.
|
|
14
|
+
* - `retryParked` is one transaction that takes the feed lock exclusively before it
|
|
15
|
+
* re-stamps the row, so its new `seq` cannot commit out of order with the
|
|
16
|
+
* materialisation's own writes (`internal/statements.ts` owns that argument).
|
|
17
|
+
* - `collectTombstones` is one statement that reads every registered sink's cursor
|
|
18
|
+
* and deletes below the lowest, or nothing while any of them has no cursor, so
|
|
19
|
+
* no cursor can move between the two.
|
|
20
|
+
* - `removeSink` is one transaction that first takes the sink's advisory lock for
|
|
21
|
+
* its own length, which an advisory worker's session lock refuses, then deletes
|
|
22
|
+
* the cursor row only when no live lease row holds it, in the same statement as
|
|
23
|
+
* that test, and the parks after it. Both tests are therefore made whichever
|
|
24
|
+
* lease kind this store was built with: a sink can be leased by either kind
|
|
25
|
+
* (`lease.ts` owns how the two coexist), and the row alone cannot say whether
|
|
26
|
+
* an advisory holder's session is alive.
|
|
27
|
+
* - `describe` is three reads, each its own snapshot, so a figure in it can be a
|
|
28
|
+
* batch older or newer than the next. It is a report for an operator, and no
|
|
29
|
+
* decision is taken on it.
|
|
30
|
+
*
|
|
31
|
+
* One store serves every feed in its prefix: unlike the in-memory feed, whose
|
|
32
|
+
* store belongs to one feed, a SQL store's state is keyed by feed name in shared
|
|
33
|
+
* tables, so a single store value is what every worker over the prefix takes.
|
|
34
|
+
*
|
|
35
|
+
* Infrastructure faults are `FeedUnreadable` on a read and `FeedWriteFailed` on a
|
|
36
|
+
* write, never defects, so the driver's supervision backs off and tries again.
|
|
37
|
+
*/
|
|
38
|
+
import {
|
|
39
|
+
CursorSuperseded,
|
|
40
|
+
type FeedCursor,
|
|
41
|
+
FeedKey,
|
|
42
|
+
FeedSeq,
|
|
43
|
+
type FeedSyncDescription,
|
|
44
|
+
type FeedSyncStore,
|
|
45
|
+
Generation,
|
|
46
|
+
LeaseHeld,
|
|
47
|
+
LeaseHolder,
|
|
48
|
+
type ParkedRecord,
|
|
49
|
+
SinkName,
|
|
50
|
+
type SinkSyncState,
|
|
51
|
+
} from '@kairos-es/feed'
|
|
52
|
+
import { Clock, DateTime, Effect, Option } from 'effect'
|
|
53
|
+
import { SqlClient } from 'effect/sql'
|
|
54
|
+
import type { FeedPostgresConfig } from './config'
|
|
55
|
+
import {
|
|
56
|
+
bigintOf,
|
|
57
|
+
feedContext,
|
|
58
|
+
unreadable,
|
|
59
|
+
writeFailed,
|
|
60
|
+
} from './internal/context'
|
|
61
|
+
import {
|
|
62
|
+
advisoryLockName,
|
|
63
|
+
type FeedLeaseConfig,
|
|
64
|
+
leaseRow,
|
|
65
|
+
makeAcquireLease,
|
|
66
|
+
} from './lease'
|
|
67
|
+
|
|
68
|
+
/** Options for {@link makePostgresFeedSyncStore}. */
|
|
69
|
+
export interface PostgresFeedSyncStoreOptions {
|
|
70
|
+
/** The `{ schema, tablePrefix }` pair the feeds were written under. */
|
|
71
|
+
readonly config?: FeedPostgresConfig
|
|
72
|
+
/** The lease kind; `leaseRow()` with its defaults when absent. */
|
|
73
|
+
readonly lease?: FeedLeaseConfig
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type CursorRow = {
|
|
77
|
+
readonly generation: string | null
|
|
78
|
+
readonly seq: string | null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type ParkedRow = {
|
|
82
|
+
readonly key: string
|
|
83
|
+
readonly seq: string
|
|
84
|
+
readonly reason: string
|
|
85
|
+
readonly parked_at_ms: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type SinkRow = CursorRow & {
|
|
89
|
+
readonly sink: string
|
|
90
|
+
readonly lease_holder: string | null
|
|
91
|
+
readonly lease_expires_at_ms: string | null
|
|
92
|
+
readonly last_advance_at_ms: string | null
|
|
93
|
+
readonly pending: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A timestamp column read back as epoch milliseconds, or `None` when NULL. */
|
|
97
|
+
const instantOf = (ms: string | null): Option.Option<DateTime.Utc> =>
|
|
98
|
+
ms === null ? Option.none() : Option.some(DateTime.makeUnsafe(Number(ms)))
|
|
99
|
+
|
|
100
|
+
const cursorOf = (row: CursorRow | undefined): Option.Option<FeedCursor> =>
|
|
101
|
+
row === undefined || row.generation === null || row.seq === null
|
|
102
|
+
? Option.none()
|
|
103
|
+
: Option.some({
|
|
104
|
+
generation: Generation.make(bigintOf(row.generation)),
|
|
105
|
+
seq: FeedSeq.make(bigintOf(row.seq)),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const parkedOf = (row: ParkedRow): ParkedRecord => ({
|
|
109
|
+
key: FeedKey.make(row.key),
|
|
110
|
+
seq: FeedSeq.make(bigintOf(row.seq)),
|
|
111
|
+
reason: row.reason,
|
|
112
|
+
parkedAt: DateTime.makeUnsafe(Number(row.parked_at_ms)),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
/** Park entries as the `jsonb` parameter the park statement takes; last per key wins. */
|
|
116
|
+
const parkJson = (park: ReadonlyArray<ParkedRecord>): string => {
|
|
117
|
+
const byKey = new Map<string, ParkedRecord>()
|
|
118
|
+
for (const record of park) {
|
|
119
|
+
byKey.delete(record.key)
|
|
120
|
+
byKey.set(record.key, record)
|
|
121
|
+
}
|
|
122
|
+
return JSON.stringify(
|
|
123
|
+
Array.from(byKey.values(), (record) => ({
|
|
124
|
+
key: record.key,
|
|
125
|
+
seq: record.seq.toString(),
|
|
126
|
+
reason: record.reason,
|
|
127
|
+
parked_at_ms: String(DateTime.toEpochMillis(record.parkedAt)),
|
|
128
|
+
})),
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the sync store over the ambient `SqlClient`. Dies at construction on a
|
|
134
|
+
* malformed config, a lease whose heartbeat is not shorter than its ttl or leaves
|
|
135
|
+
* no room for its margin, or an `advisoryLock` connection recognisably pooled.
|
|
136
|
+
* `ensureFeedSchema` must have run.
|
|
137
|
+
*/
|
|
138
|
+
export const makePostgresFeedSyncStore = (
|
|
139
|
+
options: PostgresFeedSyncStoreOptions = {},
|
|
140
|
+
): Effect.Effect<FeedSyncStore, never, SqlClient.SqlClient> =>
|
|
141
|
+
Effect.gen(function* () {
|
|
142
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms()
|
|
143
|
+
const context = feedContext(options.config ?? {})
|
|
144
|
+
const acquireLease = makeAcquireLease(
|
|
145
|
+
options.lease ?? leaseRow(),
|
|
146
|
+
sql,
|
|
147
|
+
context,
|
|
148
|
+
)
|
|
149
|
+
const { statements } = context
|
|
150
|
+
|
|
151
|
+
const readCursor: FeedSyncStore['readCursor'] = (id) =>
|
|
152
|
+
sql.unsafe<CursorRow>(statements.selectCursor, [id.feed, id.sink]).pipe(
|
|
153
|
+
Effect.mapError(unreadable(`reading the cursor of sink '${id.sink}'`)),
|
|
154
|
+
Effect.map((rows) => cursorOf(rows[0])),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const casCursor: FeedSyncStore['casCursor'] = (args) =>
|
|
158
|
+
sql
|
|
159
|
+
.withTransaction(
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const expected = Option.getOrUndefined(args.expected)
|
|
162
|
+
const now = yield* DateTime.now
|
|
163
|
+
const landed = yield* sql.unsafe(statements.casCursor, [
|
|
164
|
+
args.feed,
|
|
165
|
+
args.sink,
|
|
166
|
+
args.holder,
|
|
167
|
+
args.next.generation.toString(),
|
|
168
|
+
args.next.seq.toString(),
|
|
169
|
+
expected === undefined ? null : expected.generation.toString(),
|
|
170
|
+
expected === undefined ? null : expected.seq.toString(),
|
|
171
|
+
String(DateTime.toEpochMillis(now)),
|
|
172
|
+
])
|
|
173
|
+
if (landed.length === 0) {
|
|
174
|
+
return yield* new CursorSuperseded({
|
|
175
|
+
feed: args.feed,
|
|
176
|
+
sink: args.sink,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
const change = args.parked
|
|
180
|
+
if (change._tag === 'Replace') {
|
|
181
|
+
yield* sql.unsafe(statements.clearParked, [args.feed, args.sink])
|
|
182
|
+
} else if (change.unpark.length > 0) {
|
|
183
|
+
yield* sql.unsafe(statements.unparkKeys, [
|
|
184
|
+
args.feed,
|
|
185
|
+
args.sink,
|
|
186
|
+
JSON.stringify(change.unpark),
|
|
187
|
+
])
|
|
188
|
+
}
|
|
189
|
+
if (change.park.length > 0) {
|
|
190
|
+
yield* sql.unsafe(statements.parkKeys, [
|
|
191
|
+
args.feed,
|
|
192
|
+
args.sink,
|
|
193
|
+
parkJson(change.park),
|
|
194
|
+
])
|
|
195
|
+
}
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.pipe(
|
|
199
|
+
Effect.catchTag('SqlError', (cause) =>
|
|
200
|
+
Effect.fail(
|
|
201
|
+
writeFailed(`moving the cursor of sink '${args.sink}'`)(cause),
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const readParked: FeedSyncStore['readParked'] = (id) =>
|
|
207
|
+
sql.unsafe<ParkedRow>(statements.selectParked, [id.feed, id.sink]).pipe(
|
|
208
|
+
Effect.mapError(
|
|
209
|
+
unreadable(`reading the parked records of sink '${id.sink}'`),
|
|
210
|
+
),
|
|
211
|
+
Effect.map((rows) => rows.map(parkedOf)),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const retryParked: FeedSyncStore['retryParked'] = (args) =>
|
|
215
|
+
sql
|
|
216
|
+
.withTransaction(
|
|
217
|
+
Effect.gen(function* () {
|
|
218
|
+
yield* sql.unsafe(statements.lockFeedExclusive, [args.feed])
|
|
219
|
+
const unparked = yield* sql.unsafe(statements.unparkOne, [
|
|
220
|
+
args.feed,
|
|
221
|
+
args.sink,
|
|
222
|
+
args.key,
|
|
223
|
+
])
|
|
224
|
+
if (unparked.length === 0) {
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
yield* sql.unsafe(statements.restampRow, [args.feed, args.key])
|
|
228
|
+
return true
|
|
229
|
+
}),
|
|
230
|
+
)
|
|
231
|
+
.pipe(
|
|
232
|
+
Effect.mapError(
|
|
233
|
+
writeFailed(
|
|
234
|
+
`retrying parked record '${args.key}' of sink '${args.sink}'`,
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const collectTombstones: FeedSyncStore['collectTombstones'] = (feed) =>
|
|
240
|
+
sql.unsafe(statements.collectTombstones, [feed]).pipe(
|
|
241
|
+
Effect.mapError(
|
|
242
|
+
writeFailed(`collecting the tombstones of feed '${feed}'`),
|
|
243
|
+
),
|
|
244
|
+
Effect.map((rows) => rows.length),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
const removeSink: FeedSyncStore['removeSink'] = (id) =>
|
|
248
|
+
sql
|
|
249
|
+
.withTransaction(
|
|
250
|
+
Effect.gen(function* () {
|
|
251
|
+
const lock = yield* sql.unsafe<{ readonly taken: boolean }>(
|
|
252
|
+
statements.tryAdvisoryXactLock,
|
|
253
|
+
[advisoryLockName(context, id)],
|
|
254
|
+
)
|
|
255
|
+
if (lock[0]?.taken !== true) {
|
|
256
|
+
return yield* new LeaseHeld(id)
|
|
257
|
+
}
|
|
258
|
+
const now = yield* Clock.currentTimeMillis
|
|
259
|
+
const cursor = yield* sql.unsafe(
|
|
260
|
+
statements.deleteCursorUnlessLeased,
|
|
261
|
+
[id.feed, id.sink, String(now)],
|
|
262
|
+
)
|
|
263
|
+
if (cursor.length === 0) {
|
|
264
|
+
const left = yield* sql.unsafe(statements.cursorExists, [
|
|
265
|
+
id.feed,
|
|
266
|
+
id.sink,
|
|
267
|
+
])
|
|
268
|
+
if (left.length > 0) {
|
|
269
|
+
return yield* new LeaseHeld(id)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const parked = yield* sql.unsafe(statements.deleteSinkParked, [
|
|
273
|
+
id.feed,
|
|
274
|
+
id.sink,
|
|
275
|
+
])
|
|
276
|
+
return cursor.length > 0 || parked.length > 0
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
.pipe(
|
|
280
|
+
Effect.catchTag('SqlError', (cause) =>
|
|
281
|
+
Effect.fail(writeFailed(`removing sink '${id.sink}'`)(cause)),
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
const describe: FeedSyncStore['describe'] = (feed) =>
|
|
286
|
+
Effect.gen(function* () {
|
|
287
|
+
const head = yield* sql.unsafe<{ readonly head: string }>(
|
|
288
|
+
statements.selectHead,
|
|
289
|
+
[feed],
|
|
290
|
+
)
|
|
291
|
+
const sinks = yield* sql.unsafe<SinkRow>(statements.describeSinks, [
|
|
292
|
+
feed,
|
|
293
|
+
])
|
|
294
|
+
const parked = yield* sql.unsafe<ParkedRow & { readonly sink: string }>(
|
|
295
|
+
statements.describeParked,
|
|
296
|
+
[feed],
|
|
297
|
+
)
|
|
298
|
+
const parkedBySink = new Map<string, Array<ParkedRecord>>()
|
|
299
|
+
for (const record of parked) {
|
|
300
|
+
const list = parkedBySink.get(record.sink)
|
|
301
|
+
if (list === undefined) {
|
|
302
|
+
parkedBySink.set(record.sink, [parkedOf(record)])
|
|
303
|
+
} else {
|
|
304
|
+
list.push(parkedOf(record))
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const description: FeedSyncDescription = {
|
|
308
|
+
head: FeedSeq.make(bigintOf(head[0]?.head ?? '0')),
|
|
309
|
+
sinks: sinks.map(
|
|
310
|
+
(row): SinkSyncState => ({
|
|
311
|
+
sink: SinkName.make(row.sink),
|
|
312
|
+
cursor: cursorOf(row),
|
|
313
|
+
lastAdvance: instantOf(row.last_advance_at_ms),
|
|
314
|
+
lease:
|
|
315
|
+
row.lease_holder === null
|
|
316
|
+
? Option.none()
|
|
317
|
+
: Option.some({
|
|
318
|
+
holder: LeaseHolder.make(row.lease_holder),
|
|
319
|
+
expiresAt: instantOf(row.lease_expires_at_ms),
|
|
320
|
+
}),
|
|
321
|
+
parked: parkedBySink.get(row.sink) ?? [],
|
|
322
|
+
pending: Number(row.pending),
|
|
323
|
+
}),
|
|
324
|
+
),
|
|
325
|
+
}
|
|
326
|
+
return description
|
|
327
|
+
}).pipe(Effect.mapError(unreadable(`describing feed '${feed}'`)))
|
|
328
|
+
|
|
329
|
+
const store: FeedSyncStore = {
|
|
330
|
+
acquireLease,
|
|
331
|
+
readCursor,
|
|
332
|
+
casCursor,
|
|
333
|
+
readParked,
|
|
334
|
+
retryParked,
|
|
335
|
+
collectTombstones,
|
|
336
|
+
removeSink,
|
|
337
|
+
describe,
|
|
338
|
+
}
|
|
339
|
+
return store
|
|
340
|
+
})
|
package/src/withFeed.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `withFeed` — a `ProjectionStore` decorated so that resetting the checkpoint a
|
|
3
|
+
* feed is bound to also empties the feed and moves its generation on, in ONE
|
|
4
|
+
* transaction.
|
|
5
|
+
*
|
|
6
|
+
* ## Why a decorator
|
|
7
|
+
*
|
|
8
|
+
* Nothing in the library rebuilds a read model; a rebuild is an application
|
|
9
|
+
* resetting a checkpoint and letting the runner replay. A feed has to know when
|
|
10
|
+
* that happens, because a sink holding the old copy must be rebuilt from the
|
|
11
|
+
* replay rather than patched by it: a record the replay never writes again would
|
|
12
|
+
* otherwise survive in the sink. The reset is the one moment both facts are true,
|
|
13
|
+
* so the generation bump rides it.
|
|
14
|
+
*
|
|
15
|
+
* What it adds is the FEED's half of a rebuild and nothing more. A rebuild resets
|
|
16
|
+
* the checkpoint, drops the view, and starts again from `ORIGIN`; this resets the
|
|
17
|
+
* checkpoint and the feed, and the view is still the consumer's to drop. So
|
|
18
|
+
* rebuilding a DURABLE view means emptying it yourself before the replay, exactly
|
|
19
|
+
* as the port's own `resetCheckpoint` leaves it. Skip that and a row the replay
|
|
20
|
+
* never writes again survives in the view, and a read model whose `apply` reads
|
|
21
|
+
* its view back (a running count, say) builds on the old rows, which the feed
|
|
22
|
+
* then mirrors to every sink.
|
|
23
|
+
*
|
|
24
|
+
* The decorator keeps the port's three methods and replaces only
|
|
25
|
+
* `resetCheckpoint`, so it works through `forKey` exactly as the store does, and
|
|
26
|
+
* nothing about the read side has to know a feed exists.
|
|
27
|
+
*
|
|
28
|
+
* ## One transaction, and the client it needs
|
|
29
|
+
*
|
|
30
|
+
* The reset bumps the generation, deletes the checkpoint row through the store's
|
|
31
|
+
* own `resetCheckpoint`, and deletes the feed's rows, all inside one
|
|
32
|
+
* `withTransaction` on the client this decorator was built over. The store's own
|
|
33
|
+
* statement joins that transaction only when the store was built from the SAME
|
|
34
|
+
* client instance (ADR-0018 §3), which `@kairos-es/read-postgres`'s
|
|
35
|
+
* `makeSqlProjectionStore` is when both are built under one `SqlClient`. Build
|
|
36
|
+
* them apart and the checkpoint delete commits on its own: the reset still
|
|
37
|
+
* happens, but not atomically with the bump.
|
|
38
|
+
*
|
|
39
|
+
* The generation is bumped FIRST. That takes the feed lock exclusively, so a
|
|
40
|
+
* materialisation commit in flight finishes before the reset proceeds, and one
|
|
41
|
+
* that starts after it waits; and it takes the locks in the same order a commit
|
|
42
|
+
* does (the feed, then the checkpoint), so the two cannot deadlock. A commit that
|
|
43
|
+
* was mid-flight with the old checkpoint then loses its guard and rolls its feed
|
|
44
|
+
* rows back with its view.
|
|
45
|
+
*
|
|
46
|
+
* ## The binding
|
|
47
|
+
*
|
|
48
|
+
* A feed serves ONE materialisation, because one writer is what keeps its `seq`
|
|
49
|
+
* ascending in commit order. The binding is recorded on the feed's generations
|
|
50
|
+
* row the first time the feed is decorated, and checked at every construction
|
|
51
|
+
* after that, so a feed bound to another checkpoint key is refused across
|
|
52
|
+
* processes, not only within one. A refusal is a defect, as the read side's
|
|
53
|
+
* construction gate's are.
|
|
54
|
+
*
|
|
55
|
+
* The decorated store must be DURABLE. This feed is, and the feed's rows are
|
|
56
|
+
* written inside the store's commit transaction: an ephemeral store opens no
|
|
57
|
+
* transaction for them to join.
|
|
58
|
+
*/
|
|
59
|
+
import type { FeedWriteFailed } from '@kairos-es/feed'
|
|
60
|
+
import {
|
|
61
|
+
type CheckpointKey,
|
|
62
|
+
type ProjectionStore,
|
|
63
|
+
ProjectionStoreError,
|
|
64
|
+
} from '@kairos-es/read'
|
|
65
|
+
import { Effect } from 'effect'
|
|
66
|
+
import { SqlClient } from 'effect/sql'
|
|
67
|
+
import { contextOf, type PostgresFeed } from './feed'
|
|
68
|
+
import { writeFailed } from './internal/context'
|
|
69
|
+
|
|
70
|
+
const sameKey = (left: CheckpointKey, right: CheckpointKey): boolean =>
|
|
71
|
+
left.projection === right.projection && left.partition === right.partition
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `store`, with `resetCheckpoint` for `key` also emptying `feed` and moving its
|
|
75
|
+
* generation on; see the module doc. Every other key passes through untouched.
|
|
76
|
+
*/
|
|
77
|
+
export const withFeed = <D>(
|
|
78
|
+
store: ProjectionStore,
|
|
79
|
+
feed: PostgresFeed<D>,
|
|
80
|
+
key: CheckpointKey,
|
|
81
|
+
): Effect.Effect<ProjectionStore, FeedWriteFailed, SqlClient.SqlClient> =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const context = contextOf(feed as PostgresFeed<unknown>)
|
|
84
|
+
if (context === undefined) {
|
|
85
|
+
return yield* Effect.die(
|
|
86
|
+
new Error(
|
|
87
|
+
`withFeed: feed '${feed.feed}' was not made by makePostgresFeed`,
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if (store.durability !== 'durable') {
|
|
92
|
+
return yield* Effect.die(
|
|
93
|
+
new Error(
|
|
94
|
+
`withFeed: feed '${feed.feed}' is written inside the store's commit transaction, and an ephemeral store opens none`,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const { statements } = context
|
|
99
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms()
|
|
100
|
+
|
|
101
|
+
const bound = yield* sql
|
|
102
|
+
.unsafe<{
|
|
103
|
+
readonly projection: string | null
|
|
104
|
+
readonly partition: string | null
|
|
105
|
+
}>(statements.bindFeed, [feed.feed, key.projection, key.partition])
|
|
106
|
+
.pipe(Effect.mapError(writeFailed(`binding feed '${feed.feed}'`)))
|
|
107
|
+
const binding = bound[0]
|
|
108
|
+
if (
|
|
109
|
+
binding !== undefined &&
|
|
110
|
+
(binding.projection !== key.projection ||
|
|
111
|
+
binding.partition !== key.partition)
|
|
112
|
+
) {
|
|
113
|
+
return yield* Effect.die(
|
|
114
|
+
new Error(
|
|
115
|
+
`withFeed: feed '${feed.feed}' is already bound to projection '${binding.projection}' (partition '${binding.partition}'), and one feed serves one materialisation`,
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resetBound = (reset: CheckpointKey) =>
|
|
121
|
+
sql
|
|
122
|
+
.withTransaction(
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
yield* sql.unsafe(statements.bumpGeneration, [feed.feed])
|
|
125
|
+
yield* store.resetCheckpoint(reset)
|
|
126
|
+
yield* sql.unsafe(statements.deleteFeedRows, [feed.feed])
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
.pipe(
|
|
130
|
+
Effect.catchTag('SqlError', (cause) =>
|
|
131
|
+
Effect.fail(
|
|
132
|
+
new ProjectionStoreError({
|
|
133
|
+
reason: `resetting projection '${reset.projection}' partition '${reset.partition}' and its feed '${feed.feed}' failed: ${cause.message}`,
|
|
134
|
+
cause,
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const decorated: ProjectionStore = {
|
|
141
|
+
durability: store.durability,
|
|
142
|
+
readCheckpoint: store.readCheckpoint,
|
|
143
|
+
commit: store.commit,
|
|
144
|
+
resetCheckpoint: (reset) =>
|
|
145
|
+
sameKey(reset, key) ? resetBound(reset) : store.resetCheckpoint(reset),
|
|
146
|
+
}
|
|
147
|
+
return decorated
|
|
148
|
+
})
|