@kairos-es/feed-postgres 0.0.0-stage → 1.0.0-alpha.6

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
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@kairos-es/feed-postgres",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-alpha.6",
4
+ "description": "Postgres change-feed tables, feed writer, feed source, sync store and worker lease for kairos-es",
5
+ "license": "BSD-3-Clause",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/neverbland/kairos-es.git",
9
+ "directory": "packages/feed-postgres"
10
+ },
11
+ "sideEffects": [],
12
+ "homepage": "https://github.com/neverbland/kairos-es",
13
+ "peerDependencies": {
14
+ "effect": "^4.0.5",
15
+ "@kairos-es/core": "^1.0.0-alpha.6",
16
+ "@kairos-es/feed": "^1.0.0-alpha.6",
17
+ "@kairos-es/read": "^1.0.0-alpha.6"
18
+ },
19
+ "main": "./dist/cjs/index.js",
20
+ "module": "./dist/esm/index.js",
21
+ "types": "./dist/dts-cjs/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "import": {
25
+ "types": "./dist/dts-esm/index.d.ts",
26
+ "default": "./dist/esm/index.js"
27
+ },
28
+ "require": {
29
+ "types": "./dist/dts-cjs/index.d.ts",
30
+ "default": "./dist/cjs/index.js"
31
+ }
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "typesVersions": {
36
+ "*": {}
37
+ }
6
38
  }
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Public configuration for the Postgres change feed.
3
+ *
4
+ * A feed backend is defined by its `{ schema, tablePrefix }` naming pair alone:
5
+ * every object it owns is DERIVED from it (`internal/ddl.ts`), so one config
6
+ * value is one isolated set of feed tables, cohabiting per prefix beside the
7
+ * event store's objects and `@kairos-es/read-postgres`'s checkpoint table. Pass
8
+ * the same pair to all three and one prefix names one complete world: the log,
9
+ * the checkpoints, and the feeds the read models write.
10
+ *
11
+ * It is a `Schema` rather than a bare interface so that a malformed value fails
12
+ * LOUDLY at construction rather than obscurely at the first statement: an empty or
13
+ * dotted atom (a dot would break the qualified-name split), or a pair whose
14
+ * derived names would exceed Postgres's 63-byte identifier limit and be silently
15
+ * truncated. The shape and its checks are `@kairos-es/read-postgres`'s, written
16
+ * again here rather than imported, because each backend measures only the names it
17
+ * creates and a sibling backend's internals are not a public surface.
18
+ */
19
+ import { Schema } from 'effect'
20
+ import { oversizedIdentifier } from './internal/ddl'
21
+
22
+ /** A single identifier atom: non-empty and dot-free. */
23
+ const IdentifierAtom = Schema.String.check(
24
+ Schema.isNonEmpty(),
25
+ Schema.isPattern(/^[^.]+$/, {
26
+ identifier: 'PostgresIdentifierAtom',
27
+ description: 'a non-empty identifier atom containing no "."',
28
+ }),
29
+ )
30
+
31
+ /**
32
+ * Configuration for the feed's tables.
33
+ *
34
+ * - `schema` — the Postgres schema that owns them (default `public`).
35
+ * - `tablePrefix` — the stem every name derives from (default `dcb_events`,
36
+ * giving `dcb_events_feed_rows` and its siblings).
37
+ */
38
+ export const FeedPostgresConfig = Schema.Struct({
39
+ schema: Schema.optionalKey(IdentifierAtom),
40
+ tablePrefix: Schema.optionalKey(IdentifierAtom),
41
+ }).check(Schema.makeFilter((config) => oversizedIdentifier(config)))
42
+ export type FeedPostgresConfig = typeof FeedPostgresConfig.Type
43
+
44
+ /** Decode a config, throwing a `SchemaError` on any violation. */
45
+ export const decodeFeedPostgresConfig =
46
+ Schema.decodeUnknownSync(FeedPostgresConfig)
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `ensureFeedSchema` — the feed's idempotent DDL, as a payload for the consumer's
3
+ * migrator rather than a migrator of its own, exactly as `ensureCheckpointSchema`
4
+ * is for the read side and `ensureSchema` for the event store.
5
+ *
6
+ * It requires the GENERIC `SqlClient`, so it drops into an existing migrator:
7
+ *
8
+ * ```ts
9
+ * Migrator.fromRecord({
10
+ * '0003_kairos_feed': ensureFeedSchema(config),
11
+ * })
12
+ * ```
13
+ *
14
+ * or runs once at boot. Every statement is `IF NOT EXISTS`, so running it again
15
+ * against existing tables keeps every row. Nothing in this package performs DDL at
16
+ * runtime: a feed cannot conjure its own tables, so schema ownership stays with
17
+ * whoever owns the migrations.
18
+ */
19
+ import { Effect } from 'effect'
20
+ import { SqlClient, type SqlError } from 'effect/sql'
21
+ import { decodeFeedPostgresConfig, type FeedPostgresConfig } from './config'
22
+ import { resolveFeedNames, runFeedDdl } from './internal/ddl'
23
+
24
+ /** The feed tables' migration payload for `config`. */
25
+ export const ensureFeedSchema = (
26
+ config: FeedPostgresConfig = {},
27
+ ): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
28
+ Effect.gen(function* () {
29
+ const sql = yield* SqlClient.SqlClient
30
+ const names = resolveFeedNames(decodeFeedPostgresConfig(config))
31
+ yield* Effect.all(runFeedDdl(sql, names), { discard: true })
32
+ })
package/src/feed.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The Postgres feed's two faces: the `BatchedFeedWriter` a read model's `apply`
3
+ * writes through, and the `FeedSource` a sync worker tails.
4
+ *
5
+ * ## The writer rides the read model's own client
6
+ *
7
+ * A feed row must exist if and only if the view write and the checkpoint advance
8
+ * that produced it landed (ADR-0019 §2). `@kairos-es/read-postgres`'s `commit` is
9
+ * one transaction over the client the store was built from, and `effect/sql`
10
+ * carries that transaction in the Effect CONTEXT under a key that belongs to that
11
+ * one client instance (ADR-0018 §3). So the writer does not capture a client: it
12
+ * reads `SqlClient` from the context AT WRITE TIME, which inside `commit`'s
13
+ * `viewWrites` is the read model's own, and its statements join the open
14
+ * transaction and roll back with it on a lost guard. That is why `SqlClient` sits
15
+ * in the writer's `RW` and not in the factory's requirements alone. A write
16
+ * through any OTHER client instance is not bracketed, and the exactly-once
17
+ * property does not extend to it.
18
+ *
19
+ * ## What one `write` sends
20
+ *
21
+ * The body's upserts and removes are buffered, last write per key winning, and
22
+ * land as at most three statements whatever the batch size: the feed lock (see
23
+ * `internal/statements.ts` for why the writer takes it), one upsert of every
24
+ * changed document and one tombstone of every removed key. The upsert writes
25
+ * nothing for a document equal to the stored one, compared as `jsonb`, so key
26
+ * order is no change; the tombstone writes nothing for a key that is absent or
27
+ * already a tombstone.
28
+ *
29
+ * Each document is encoded through its `Schema` before anything is sent. A
30
+ * document that fails to encode is a DEFECT in the read model, not a fault of the
31
+ * feed and not something a sink should ever be asked to refuse.
32
+ *
33
+ * ## The source
34
+ *
35
+ * `changesAfter` pages the feed's rows by `seq`, tombstones included, and decodes
36
+ * each document through the same `Schema`. A stored document that no longer
37
+ * decodes is a defect too: it means the schema changed without the feed being
38
+ * rebuilt, which retrying cannot mend.
39
+ *
40
+ * `listen` is optional and is a WAKE only. This package takes no reserved
41
+ * connection for a `LISTEN` of its own, as `@kairos-es/read-postgres` takes none,
42
+ * so the poll is the guarantee. {@link eventStoreWake} offers the event store's
43
+ * `subscribe` as a wake: it fires when an event is appended, which is BEFORE the
44
+ * projection has applied it and written the feed, so a woken read may find nothing
45
+ * new and the poll picks the row up. It shortens the wait; it never decides
46
+ * correctness.
47
+ */
48
+ import {
49
+ DcbEventStore,
50
+ Position,
51
+ type Query,
52
+ type ReadLimit,
53
+ } from '@kairos-es/core'
54
+ import {
55
+ type BatchedFeedWriter,
56
+ FeedKey,
57
+ type FeedName,
58
+ type FeedRow,
59
+ FeedSeq,
60
+ type FeedSource,
61
+ type FeedUnreadable,
62
+ type FeedWriteFailed,
63
+ type FeedWriter,
64
+ Generation,
65
+ } from '@kairos-es/feed'
66
+ import { Effect, Option, Schema, Stream } from 'effect'
67
+ import { SqlClient } from 'effect/sql'
68
+ import type { FeedPostgresConfig } from './config'
69
+ import {
70
+ bigintOf,
71
+ type FeedContext,
72
+ feedContext,
73
+ unreadable,
74
+ writeFailed,
75
+ } from './internal/context'
76
+
77
+ /** A Postgres feed: its writer, and the source a sync worker reads. */
78
+ export interface PostgresFeed<D>
79
+ extends BatchedFeedWriter<D, SqlClient.SqlClient> {
80
+ readonly source: FeedSource<D>
81
+ }
82
+
83
+ /** Options for {@link makePostgresFeed}. */
84
+ export interface PostgresFeedOptions<D, I> {
85
+ /** The `{ schema, tablePrefix }` pair; the same one the event store and read side take. */
86
+ readonly config?: FeedPostgresConfig
87
+ readonly feed: FeedName
88
+ /** The document's schema: what the writer encodes through and the source decodes through. */
89
+ readonly doc: Schema.Codec<D, I>
90
+ /** A wake for the sync worker, such as {@link eventStoreWake}'s. The poll is the guarantee. */
91
+ readonly listen?: Stream.Stream<unknown, FeedUnreadable>
92
+ }
93
+
94
+ /** The context a feed was built over, for `withFeed`. */
95
+ const contexts = new WeakMap<object, FeedContext>()
96
+
97
+ /** @internal */
98
+ export const contextOf = (
99
+ feed: PostgresFeed<unknown>,
100
+ ): FeedContext | undefined => contexts.get(feed)
101
+
102
+ type ChangeRow = {
103
+ readonly key: string
104
+ readonly doc: string | null
105
+ readonly seq: string
106
+ readonly event_position: string
107
+ }
108
+
109
+ /**
110
+ * Build a feed over the ambient `SqlClient`. Creates the feed's generations row
111
+ * if it is absent, at generation 0, so the writer's feed lock always has a row to
112
+ * take; that is the only write it makes. `ensureFeedSchema` must have run first.
113
+ */
114
+ export const makePostgresFeed = <D, I>(
115
+ options: PostgresFeedOptions<D, I>,
116
+ ): Effect.Effect<PostgresFeed<D>, FeedWriteFailed, SqlClient.SqlClient> =>
117
+ Effect.gen(function* () {
118
+ const context = feedContext(options.config ?? {})
119
+ const { statements } = context
120
+ const feed = options.feed
121
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms()
122
+ const encode = Schema.encodeEffect(options.doc)
123
+ const decode = Schema.decodeUnknownEffect(options.doc)
124
+
125
+ yield* sql
126
+ .unsafe(statements.ensureGeneration, [feed])
127
+ .pipe(
128
+ Effect.mapError(
129
+ writeFailed(`creating the generation of feed '${feed}'`),
130
+ ),
131
+ )
132
+
133
+ const write = <A, E, R>(
134
+ at: Position,
135
+ body: (writer: FeedWriter<D>) => Effect.Effect<A, E, R>,
136
+ ): Effect.Effect<A, E | FeedWriteFailed, R | SqlClient.SqlClient> =>
137
+ Effect.gen(function* () {
138
+ const buffer = new Map<FeedKey, Option.Option<D>>()
139
+ const set = (key: FeedKey, doc: Option.Option<D>) =>
140
+ Effect.sync(() => {
141
+ buffer.delete(key)
142
+ buffer.set(key, doc)
143
+ })
144
+ const result = yield* body({
145
+ upsert: (key, doc) => set(key, Option.some(doc)),
146
+ remove: (key) => set(key, Option.none()),
147
+ })
148
+ if (buffer.size === 0) {
149
+ return result
150
+ }
151
+ const upserts: Array<{ readonly key: FeedKey; readonly doc: I }> = []
152
+ const removes: Array<FeedKey> = []
153
+ for (const [key, doc] of buffer) {
154
+ if (Option.isSome(doc)) {
155
+ upserts.push({ key, doc: yield* Effect.orDie(encode(doc.value)) })
156
+ } else {
157
+ removes.push(key)
158
+ }
159
+ }
160
+ const upsertJson =
161
+ upserts.length === 0
162
+ ? undefined
163
+ : yield* Effect.try({
164
+ try: () => JSON.stringify(upserts),
165
+ catch: (cause) =>
166
+ new Error(
167
+ `feed '${feed}': an encoded document is not JSON-serialisable`,
168
+ { cause },
169
+ ),
170
+ }).pipe(Effect.orDie)
171
+ // The read model's own client, from the context at write time; see the
172
+ // module doc. Transform-free, for the same reason the read side's store
173
+ // is: the copy keeps the original's transaction key.
174
+ const client = (yield* SqlClient.SqlClient).withoutTransforms()
175
+ const position = at.toString()
176
+ yield* client
177
+ .withTransaction(
178
+ Effect.gen(function* () {
179
+ yield* client.unsafe(statements.lockFeedShared, [feed])
180
+ if (upsertJson !== undefined) {
181
+ yield* client.unsafe(statements.upsertRows, [
182
+ feed,
183
+ position,
184
+ upsertJson,
185
+ ])
186
+ }
187
+ if (removes.length > 0) {
188
+ yield* client.unsafe(statements.tombstoneRows, [
189
+ feed,
190
+ position,
191
+ JSON.stringify(removes),
192
+ ])
193
+ }
194
+ }),
195
+ )
196
+ .pipe(Effect.mapError(writeFailed(`writing feed '${feed}'`)))
197
+ return result
198
+ })
199
+
200
+ const decodeRow = (row: ChangeRow): Effect.Effect<FeedRow<D>> =>
201
+ Effect.gen(function* () {
202
+ // Bound to a `const` so the `null` check narrows inside the `try` below.
203
+ const stored = row.doc
204
+ const doc =
205
+ stored === null
206
+ ? Option.none<D>()
207
+ : Option.some(
208
+ yield* Effect.orDie(
209
+ Effect.flatMap(
210
+ Effect.try({
211
+ try: () => JSON.parse(stored) as unknown,
212
+ catch: (cause) =>
213
+ new Error(
214
+ `feed '${feed}': a stored document is not JSON`,
215
+ {
216
+ cause,
217
+ },
218
+ ),
219
+ }),
220
+ (json) => decode(json),
221
+ ),
222
+ ),
223
+ )
224
+ return {
225
+ key: FeedKey.make(row.key),
226
+ doc,
227
+ seq: FeedSeq.make(bigintOf(row.seq)),
228
+ eventPosition: Position.make(bigintOf(row.event_position)),
229
+ }
230
+ })
231
+
232
+ const source: FeedSource<D> = {
233
+ feed,
234
+ generation: sql
235
+ .unsafe<{ readonly generation: string }>(statements.selectGeneration, [
236
+ feed,
237
+ ])
238
+ .pipe(
239
+ Effect.mapError(
240
+ unreadable(`reading the generation of feed '${feed}'`),
241
+ ),
242
+ Effect.map((rows) => {
243
+ const stored = rows[0]?.generation
244
+ return Generation.make(stored === undefined ? 0n : bigintOf(stored))
245
+ }),
246
+ ),
247
+ changesAfter: (after: FeedSeq, limit: ReadLimit) =>
248
+ sql
249
+ .unsafe<ChangeRow>(statements.changesAfter, [
250
+ feed,
251
+ after.toString(),
252
+ limit,
253
+ ])
254
+ .pipe(
255
+ Effect.mapError(unreadable(`reading feed '${feed}'`)),
256
+ Effect.flatMap((rows) => Effect.forEach(rows, decodeRow)),
257
+ ),
258
+ ...(options.listen === undefined ? {} : { listen: options.listen }),
259
+ }
260
+
261
+ const result: PostgresFeed<D> = { feed, write, source }
262
+ contexts.set(result, context)
263
+ return result
264
+ })
265
+
266
+ /**
267
+ * A wake from the event store: one element per event matching `query` appended
268
+ * after the moment the stream is subscribed, and nothing else. Pass it as
269
+ * `listen`. Early by the projection's own lag, as the module doc says, so the
270
+ * worker's poll still bounds the latency; `query` should be the read model's own,
271
+ * so events it never applies wake nothing.
272
+ */
273
+ export const eventStoreWake = (
274
+ query: Query,
275
+ ): Effect.Effect<Stream.Stream<unknown>, never, DcbEventStore> =>
276
+ Effect.gen(function* () {
277
+ const store = yield* DcbEventStore
278
+ const wake: Stream.Stream<unknown> = Stream.unwrap(
279
+ Effect.map(store.read(query), (response) =>
280
+ store.subscribe(query, response.head),
281
+ ),
282
+ ).pipe(Stream.scoped, Stream.as(undefined))
283
+ return wake
284
+ })
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @kairos-es/feed-postgres — the Postgres change feed: the feed tables and their
3
+ * idempotent DDL, the `FeedWriter` that rides a read model's own `SqlClient`
4
+ * inside its commit, the `FeedSource`, the `FeedSyncStore` with its worker lease,
5
+ * and the store decorator that moves the feed's generation on a reset
6
+ * (ADR-0019).
7
+ *
8
+ * A package of its own rather than part of `@kairos-es/read-postgres`, so that a
9
+ * read model with no feed gains no `@kairos-es/feed` peer (ADR-0003). It needs
10
+ * only the GENERIC `SqlClient`, and takes no reserved connection unless the
11
+ * consumer chooses the advisory-lock lease and dedicates one to it.
12
+ *
13
+ * Where each part's argument lives:
14
+ *
15
+ * - `config.ts` and `internal/ddl.ts` — the `{ schema, tablePrefix }` pair and the
16
+ * five objects derived from it; `ensureFeedSchema.ts` — the migration payload.
17
+ * - `internal/statements.ts` — every statement the package sends, and the feed
18
+ * lock that keeps `seq` ascending in commit order.
19
+ * - `feed.ts` — the writer, the source and the wake.
20
+ * - `syncStore.ts` — the cursor, the parked list and their atomic steps.
21
+ * - `lease.ts` — the two lease kinds, and what each costs.
22
+ * - `withFeed.ts` — the reset, the generation and the binding.
23
+ *
24
+ * Wiring, end to end:
25
+ *
26
+ * ```ts
27
+ * const config = { schema: 'public', tablePrefix: 'dcb_events' }
28
+ * const key = checkpointKey(ProjectionId.make('course-search'))
29
+ *
30
+ * // 1. In YOUR migrator, beside `ensureSchema` and `ensureCheckpointSchema`:
31
+ * // ensureFeedSchema(config)
32
+ *
33
+ * // 2. Where the read model is wired, under ONE SqlClient:
34
+ * const feed = yield* makePostgresFeed({ config, feed: FeedName.make('course-search'), doc: CourseDoc })
35
+ * const store = yield* withFeed(yield* makeSqlProjectionStore(config), feed, key)
36
+ * // …and in the read model's `apply`, inside `commit`:
37
+ * // feed.write(lastPosition, (writer) => writer.upsert(FeedKey.make(id), toDoc(course)))
38
+ *
39
+ * // 3. Beside the projections, one worker per feed and sink:
40
+ * const syncStore = yield* makePostgresFeedSyncStore({ config })
41
+ * yield* Effect.forkScoped(runFeedSync({ source: feed.source, sink, store: syncStore }))
42
+ * ```
43
+ */
44
+ export {
45
+ decodeFeedPostgresConfig,
46
+ FeedPostgresConfig,
47
+ } from './config'
48
+ export { ensureFeedSchema } from './ensureFeedSchema'
49
+ export {
50
+ eventStoreWake,
51
+ makePostgresFeed,
52
+ type PostgresFeed,
53
+ type PostgresFeedOptions,
54
+ } from './feed'
55
+ export {
56
+ advisoryLock,
57
+ DEFAULT_LEASE_HEARTBEAT,
58
+ DEFAULT_LEASE_TTL,
59
+ type FeedLeaseConfig,
60
+ leaseRow,
61
+ pooledConnectionFault,
62
+ } from './lease'
63
+ export {
64
+ makePostgresFeedSyncStore,
65
+ type PostgresFeedSyncStoreOptions,
66
+ } from './syncStore'
67
+ export { withFeed } from './withFeed'
@@ -0,0 +1,44 @@
1
+ /**
2
+ * What every part of this package shares about one config: the resolved names,
3
+ * the compiled statements, and the error mapping from `SqlError` to the feed's own
4
+ * two infrastructure errors.
5
+ *
6
+ * A `PostgresFeed` carries its context privately, in a `WeakMap` keyed by the
7
+ * feed value, so `withFeed` can reach the feed's names and statements without the
8
+ * public type growing a field no consumer should touch.
9
+ */
10
+ import { FeedUnreadable, FeedWriteFailed } from '@kairos-es/feed'
11
+ import type { SqlError } from 'effect/sql'
12
+ import { decodeFeedPostgresConfig, type FeedPostgresConfig } from '../config'
13
+ import { type ResolvedFeedNames, resolveFeedNames } from './ddl'
14
+ import { compileFeedStatements, type FeedStatements } from './statements'
15
+
16
+ /** One config's names and statements. */
17
+ export interface FeedContext {
18
+ readonly names: ResolvedFeedNames
19
+ readonly statements: FeedStatements
20
+ }
21
+
22
+ /** Decode `config` and compile its statements; throws on a malformed config. */
23
+ export const feedContext = (config: FeedPostgresConfig): FeedContext => {
24
+ const names = resolveFeedNames(decodeFeedPostgresConfig(config))
25
+ return { names, statements: compileFeedStatements(names) }
26
+ }
27
+
28
+ /** A read that failed, named for what was being read. */
29
+ export const unreadable =
30
+ (attempt: string) =>
31
+ (cause: SqlError.SqlError): FeedUnreadable =>
32
+ new FeedUnreadable({ reason: `${attempt} failed: ${cause.message}`, cause })
33
+
34
+ /** A write that failed, named for what was being written. */
35
+ export const writeFailed =
36
+ (attempt: string) =>
37
+ (cause: SqlError.SqlError): FeedWriteFailed =>
38
+ new FeedWriteFailed({
39
+ reason: `${attempt} failed: ${cause.message}`,
40
+ cause,
41
+ })
42
+
43
+ /** A 64-bit column read back as text, as a `bigint`; see `statements.ts`. */
44
+ export const bigintOf = (text: string | bigint | number): bigint => BigInt(text)