@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/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kairos-es/feed-postgres",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.0",
|
|
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.5",
|
|
16
|
+
"@kairos-es/feed": "^0.0.0",
|
|
17
|
+
"@kairos-es/read": "^1.0.0-alpha.5"
|
|
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)
|