@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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +84 -2
  3. package/dist/cjs/config.js +46 -0
  4. package/dist/cjs/config.js.map +1 -0
  5. package/dist/cjs/ensureFeedSchema.js +39 -0
  6. package/dist/cjs/ensureFeedSchema.js.map +1 -0
  7. package/dist/cjs/feed.js +176 -0
  8. package/dist/cjs/feed.js.map +1 -0
  9. package/dist/cjs/index.js +84 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/context.js +45 -0
  12. package/dist/cjs/internal/context.js.map +1 -0
  13. package/dist/cjs/internal/ddl.js +149 -0
  14. package/dist/cjs/internal/ddl.js.map +1 -0
  15. package/dist/cjs/internal/statements.js +270 -0
  16. package/dist/cjs/internal/statements.js.map +1 -0
  17. package/dist/cjs/lease.js +342 -0
  18. package/dist/cjs/lease.js.map +1 -0
  19. package/dist/cjs/syncStore.js +179 -0
  20. package/dist/cjs/syncStore.js.map +1 -0
  21. package/dist/cjs/withFeed.js +51 -0
  22. package/dist/cjs/withFeed.js.map +1 -0
  23. package/dist/dts-cjs/config.d.ts +37 -0
  24. package/dist/dts-cjs/config.d.ts.map +1 -0
  25. package/dist/dts-cjs/ensureFeedSchema.d.ts +24 -0
  26. package/dist/dts-cjs/ensureFeedSchema.d.ts.map +1 -0
  27. package/dist/dts-cjs/feed.d.ts +81 -0
  28. package/dist/dts-cjs/feed.d.ts.map +1 -0
  29. package/dist/dts-cjs/index.d.ts +50 -0
  30. package/dist/dts-cjs/index.d.ts.map +1 -0
  31. package/dist/dts-cjs/internal/context.d.ts +28 -0
  32. package/dist/dts-cjs/internal/context.d.ts.map +1 -0
  33. package/dist/dts-cjs/internal/ddl.d.ts +99 -0
  34. package/dist/dts-cjs/internal/ddl.d.ts.map +1 -0
  35. package/dist/dts-cjs/internal/statements.d.ts +127 -0
  36. package/dist/dts-cjs/internal/statements.d.ts.map +1 -0
  37. package/dist/dts-cjs/lease.d.ts +138 -0
  38. package/dist/dts-cjs/lease.d.ts.map +1 -0
  39. package/dist/dts-cjs/package.json +3 -0
  40. package/dist/dts-cjs/syncStore.d.ts +57 -0
  41. package/dist/dts-cjs/syncStore.d.ts.map +1 -0
  42. package/dist/dts-cjs/withFeed.d.ts +69 -0
  43. package/dist/dts-cjs/withFeed.d.ts.map +1 -0
  44. package/dist/dts-esm/config.d.ts +37 -0
  45. package/dist/dts-esm/config.d.ts.map +1 -0
  46. package/dist/dts-esm/ensureFeedSchema.d.ts +24 -0
  47. package/dist/dts-esm/ensureFeedSchema.d.ts.map +1 -0
  48. package/dist/dts-esm/feed.d.ts +81 -0
  49. package/dist/dts-esm/feed.d.ts.map +1 -0
  50. package/dist/dts-esm/index.d.ts +50 -0
  51. package/dist/dts-esm/index.d.ts.map +1 -0
  52. package/dist/dts-esm/internal/context.d.ts +28 -0
  53. package/dist/dts-esm/internal/context.d.ts.map +1 -0
  54. package/dist/dts-esm/internal/ddl.d.ts +99 -0
  55. package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
  56. package/dist/dts-esm/internal/statements.d.ts +127 -0
  57. package/dist/dts-esm/internal/statements.d.ts.map +1 -0
  58. package/dist/dts-esm/lease.d.ts +138 -0
  59. package/dist/dts-esm/lease.d.ts.map +1 -0
  60. package/dist/dts-esm/package.json +3 -0
  61. package/dist/dts-esm/syncStore.d.ts +57 -0
  62. package/dist/dts-esm/syncStore.d.ts.map +1 -0
  63. package/dist/dts-esm/withFeed.d.ts +69 -0
  64. package/dist/dts-esm/withFeed.d.ts.map +1 -0
  65. package/dist/esm/config.js +39 -0
  66. package/dist/esm/config.js.map +1 -0
  67. package/dist/esm/ensureFeedSchema.js +31 -0
  68. package/dist/esm/ensureFeedSchema.js.map +1 -0
  69. package/dist/esm/feed.js +166 -0
  70. package/dist/esm/feed.js.map +1 -0
  71. package/dist/esm/index.js +50 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/internal/context.js +34 -0
  74. package/dist/esm/internal/context.js.map +1 -0
  75. package/dist/esm/internal/ddl.js +136 -0
  76. package/dist/esm/internal/ddl.js.map +1 -0
  77. package/dist/esm/internal/statements.js +262 -0
  78. package/dist/esm/internal/statements.js.map +1 -0
  79. package/dist/esm/lease.js +330 -0
  80. package/dist/esm/lease.js.map +1 -0
  81. package/dist/esm/package.json +4 -0
  82. package/dist/esm/syncStore.js +171 -0
  83. package/dist/esm/syncStore.js.map +1 -0
  84. package/dist/esm/withFeed.js +44 -0
  85. package/dist/esm/withFeed.js.map +1 -0
  86. package/package.json +35 -3
  87. package/src/config.ts +46 -0
  88. package/src/ensureFeedSchema.ts +32 -0
  89. package/src/feed.ts +284 -0
  90. package/src/index.ts +67 -0
  91. package/src/internal/context.ts +44 -0
  92. package/src/internal/ddl.ts +233 -0
  93. package/src/internal/statements.ts +387 -0
  94. package/src/lease.ts +503 -0
  95. package/src/syncStore.ts +340 -0
  96. package/src/withFeed.ts +148 -0
@@ -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
+ })
@@ -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
+ })