@kairos-es/store-postgres 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 +167 -0
- package/dist/cjs/clients.js +62 -0
- package/dist/cjs/clients.js.map +1 -0
- package/dist/cjs/config.js +94 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureSchema.js +53 -0
- package/dist/cjs/ensureSchema.js.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/ddl.js +436 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/matchSql.js +189 -0
- package/dist/cjs/internal/matchSql.js.map +1 -0
- package/dist/cjs/internal/readPlan.js +67 -0
- package/dist/cjs/internal/readPlan.js.map +1 -0
- package/dist/cjs/internal/subscribe.js +120 -0
- package/dist/cjs/internal/subscribe.js.map +1 -0
- package/dist/cjs/internal/transport.js +66 -0
- package/dist/cjs/internal/transport.js.map +1 -0
- package/dist/cjs/store.js +256 -0
- package/dist/cjs/store.js.map +1 -0
- package/dist/dts/clients.d.ts +34 -0
- package/dist/dts/clients.d.ts.map +1 -0
- package/dist/dts/config.d.ts +58 -0
- package/dist/dts/config.d.ts.map +1 -0
- package/dist/dts/ensureSchema.d.ts +35 -0
- package/dist/dts/ensureSchema.d.ts.map +1 -0
- package/dist/dts/index.d.ts +41 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/ddl.d.ts +174 -0
- package/dist/dts/internal/ddl.d.ts.map +1 -0
- package/dist/dts/internal/matchSql.d.ts +140 -0
- package/dist/dts/internal/matchSql.d.ts.map +1 -0
- package/dist/dts/internal/readPlan.d.ts +72 -0
- package/dist/dts/internal/readPlan.d.ts.map +1 -0
- package/dist/dts/internal/subscribe.d.ts +87 -0
- package/dist/dts/internal/subscribe.d.ts.map +1 -0
- package/dist/dts/internal/transport.d.ts +116 -0
- package/dist/dts/internal/transport.d.ts.map +1 -0
- package/dist/dts/store.d.ts +17 -0
- package/dist/dts/store.d.ts.map +1 -0
- package/dist/esm/clients.js +51 -0
- package/dist/esm/clients.js.map +1 -0
- package/dist/esm/config.js +87 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureSchema.js +45 -0
- package/dist/esm/ensureSchema.js.map +1 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ddl.js +424 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/matchSql.js +176 -0
- package/dist/esm/internal/matchSql.js.map +1 -0
- package/dist/esm/internal/readPlan.js +59 -0
- package/dist/esm/internal/readPlan.js.map +1 -0
- package/dist/esm/internal/subscribe.js +113 -0
- package/dist/esm/internal/subscribe.js.map +1 -0
- package/dist/esm/internal/transport.js +56 -0
- package/dist/esm/internal/transport.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/store.js +249 -0
- package/dist/esm/store.js.map +1 -0
- package/package.json +35 -0
- package/src/clients.ts +69 -0
- package/src/config.ts +92 -0
- package/src/ensureSchema.ts +46 -0
- package/src/index.ts +45 -0
- package/src/internal/ddl.ts +599 -0
- package/src/internal/matchSql.ts +219 -0
- package/src/internal/readPlan.ts +117 -0
- package/src/internal/transport.ts +141 -0
- package/src/store.ts +413 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Postgres `DcbEventStore` engine: the runtime `read`/`append`/`subscribe`
|
|
3
|
+
* over the two-table B-tree + exclusive-lock design (ADR-0002). It satisfies the
|
|
4
|
+
* SAME `DcbEventStore` contract as the in-memory oracle, so the shared
|
|
5
|
+
* `store-contract-tests` suite runs unchanged against it with in-memory as the
|
|
6
|
+
* parity reference.
|
|
7
|
+
*
|
|
8
|
+
* Concurrency correctness does NOT live in TypeScript here — it lives in the
|
|
9
|
+
* plpgsql append function's `LOCK TABLE … IN EXCLUSIVE MODE` (see
|
|
10
|
+
* `internal/ddl.ts`). The TS side is transport: shape a `Query`/event batch into
|
|
11
|
+
* JSON, call the function, and map rows back to core types. In particular:
|
|
12
|
+
*
|
|
13
|
+
* - a CONFLICT is a ZERO-ROW function result, read from the row count — NEVER a
|
|
14
|
+
* caught SQL error. `append` maps empty → `AppendConditionFailed`, one row →
|
|
15
|
+
* the `MAX(id)` head position.
|
|
16
|
+
* - `position` is `bigserial`, so it is strictly-increasing but NOT gapless; we
|
|
17
|
+
* order by `id` alone and never assume contiguity.
|
|
18
|
+
* - `subscribe` opens `listen` on `PgClientDirect` BEFORE the catch-up read, then
|
|
19
|
+
* reconciles by paged reads and de-dups the live tail past the caught-up
|
|
20
|
+
* boundary — the one place the two clients diverge (the Neon trap).
|
|
21
|
+
*
|
|
22
|
+
* That zero-row signal cannot be FORGED by a lost response, and what buys it is
|
|
23
|
+
* the absence of a retry rather than a check. `append` issues its guarded
|
|
24
|
+
* statement exactly ONCE: nothing in this engine re-issues one, and
|
|
25
|
+
* `retryOnConflict` resumes only on `AppendConditionFailed`, which an
|
|
26
|
+
* infrastructure fault is not — `makeContractStore` dies it, off the error
|
|
27
|
+
* channel a retry watches. So a write that reaches the server, commits, and
|
|
28
|
+
* loses its response surfaces as a DEFECT over an outcome this engine does not
|
|
29
|
+
* resolve, never as a conflict that never happened; there is no second issue of
|
|
30
|
+
* the guarded statement to meet its own row and read zero. Surfacing rather than
|
|
31
|
+
* RESOLVING is what makes that free: an engine willing to re-issue an undecided
|
|
32
|
+
* append needs a way to tell its own committed row from a rival's BEFORE it does,
|
|
33
|
+
* or the re-issue is the very thing that manufactures the phantom — see
|
|
34
|
+
* `@kairos-es/store-sqlite`, which resolves one by identity and re-issues only
|
|
35
|
+
* when identity answers absent. So the missing verify path here is a consequence
|
|
36
|
+
* of that choice rather than an omission from it.
|
|
37
|
+
*
|
|
38
|
+
* This module is WIRING + APPEND: the JSON/JSONB transport (the event/query-item
|
|
39
|
+
* (de)serialisers and row mapping) lives in `internal/transport.ts` and the
|
|
40
|
+
* read-plan compiler in `internal/readPlan.ts`. The wake/poll/de-dup subscribe
|
|
41
|
+
* machine is NOT here either — it is core's `subscribeStream`
|
|
42
|
+
* (`packages/core/src/SubscribeMachine.ts`), shared by every engine, and this
|
|
43
|
+
* module only hands it a `fetch` and a `listen` built from the two clients.
|
|
44
|
+
*/
|
|
45
|
+
import type { SqlClient, SqlError } from '@effect/sql'
|
|
46
|
+
import type {
|
|
47
|
+
DcbEvent,
|
|
48
|
+
Position,
|
|
49
|
+
ReadOptions,
|
|
50
|
+
ReadResponse,
|
|
51
|
+
SequencedEvent,
|
|
52
|
+
} from '@kairos-es/core'
|
|
53
|
+
import {
|
|
54
|
+
type AppendCondition,
|
|
55
|
+
AppendConditionFailed,
|
|
56
|
+
DcbEventStore,
|
|
57
|
+
DEFAULT_CATCH_UP_PAGE_SIZE,
|
|
58
|
+
DEFAULT_POLL_INTERVAL_MILLIS,
|
|
59
|
+
makeContractStore,
|
|
60
|
+
ORIGIN,
|
|
61
|
+
type Query,
|
|
62
|
+
type RawDcbEventStore,
|
|
63
|
+
subscribeStream,
|
|
64
|
+
} from '@kairos-es/core'
|
|
65
|
+
import { type Array as Arr, Effect, Layer, type Scope, Stream } from 'effect'
|
|
66
|
+
import { PgClientDirect, PgClientPooled } from './clients'
|
|
67
|
+
import { decodePostgresStoreConfig, type PostgresStoreConfig } from './config'
|
|
68
|
+
import {
|
|
69
|
+
quoteQualified,
|
|
70
|
+
type ResolvedNames,
|
|
71
|
+
resolveNames,
|
|
72
|
+
} from './internal/ddl'
|
|
73
|
+
import {
|
|
74
|
+
compileFetchPlan,
|
|
75
|
+
type ReadTables,
|
|
76
|
+
runFetch,
|
|
77
|
+
} from './internal/readPlan'
|
|
78
|
+
import {
|
|
79
|
+
type EventRow,
|
|
80
|
+
jsonbText,
|
|
81
|
+
rowToSequenced,
|
|
82
|
+
toEventJson,
|
|
83
|
+
toQueryItemsJson,
|
|
84
|
+
} from './internal/transport'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Set `lock_timeout` for the current transaction via a BOUND parameter.
|
|
88
|
+
*
|
|
89
|
+
* `SET LOCAL` cannot take a bound parameter, but `set_config(setting, value,
|
|
90
|
+
* is_local)` is its exact bound-parameter equivalent (`is_local = true` ≡ `SET
|
|
91
|
+
* LOCAL`) — so the timeout value is a parameter, never interpolated into SQL
|
|
92
|
+
* text. The value is `'<seconds>s'` (Postgres parses the `s` unit); `'0s'` = no
|
|
93
|
+
* timeout. Runs inside the append transaction so it scopes to that call only.
|
|
94
|
+
*/
|
|
95
|
+
const setLocalLockTimeout = (
|
|
96
|
+
sql: SqlClient.SqlClient,
|
|
97
|
+
lockTimeout: number,
|
|
98
|
+
): Effect.Effect<unknown, SqlError.SqlError> =>
|
|
99
|
+
sql`SELECT set_config('lock_timeout', ${`${lockTimeout}s`}, true)`
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the engine MECHANICS (a `RawDcbEventStore`) over the two clients and a
|
|
103
|
+
* resolved name set. Contract policy (`assertServableQuery`, `ReadLimit` decode,
|
|
104
|
+
* error-vs-defect classification) is NOT here — `makeContractStore` adds it
|
|
105
|
+
* uniformly, so this engine surfaces raw `SqlError`s and the contract conflict.
|
|
106
|
+
*/
|
|
107
|
+
const make = (
|
|
108
|
+
config: PostgresStoreConfig,
|
|
109
|
+
names: ResolvedNames,
|
|
110
|
+
): Effect.Effect<
|
|
111
|
+
RawDcbEventStore<SqlError.SqlError>,
|
|
112
|
+
never,
|
|
113
|
+
PgClientPooled | PgClientDirect
|
|
114
|
+
> =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const pooled = yield* PgClientPooled
|
|
117
|
+
const direct = yield* PgClientDirect
|
|
118
|
+
const lockTimeout = config.lockTimeout ?? 0
|
|
119
|
+
const pollInterval = config.pollInterval ?? DEFAULT_POLL_INTERVAL_MILLIS
|
|
120
|
+
const catchUpPageSize = config.catchUpPageSize ?? DEFAULT_CATCH_UP_PAGE_SIZE
|
|
121
|
+
|
|
122
|
+
// The pre-quoted table names every read plan embeds (resolved once).
|
|
123
|
+
const tables: ReadTables = {
|
|
124
|
+
main: quoteQualified(names.mainTable),
|
|
125
|
+
tag: quoteQualified(names.tagTable),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --- read -------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The global last position ignoring any filter — the head of a no-limit
|
|
132
|
+
* read. Empty store → ORIGIN.
|
|
133
|
+
*
|
|
134
|
+
* A VALUE over the resolved `pooled`, and deliberately NOT a function of a
|
|
135
|
+
* client — do not re-introduce the parameter. There is only one client it
|
|
136
|
+
* could take: `direct` exists solely for `subscribe`'s `listen` (see
|
|
137
|
+
* `clients.ts`), so a client parameter would advertise a selection seam the
|
|
138
|
+
* engine does not have.
|
|
139
|
+
*
|
|
140
|
+
* Nor would such a parameter be load-bearing for the REPEATABLE READ
|
|
141
|
+
* transaction this runs inside, which is the trap worth naming, since a
|
|
142
|
+
* closed-over client LOOKS like it must escape to its own snapshot.
|
|
143
|
+
* `@effect/sql` resolves a statement's connection when the statement RUNS,
|
|
144
|
+
* from the FIBRE CONTEXT rather than from the client value: `withTransaction`
|
|
145
|
+
* acquires one connection and `Effect.locally`s it into
|
|
146
|
+
* `FiberRef.currentContext` under its `TransactionConnection` tag, and every
|
|
147
|
+
* statement a client builds resolves through a `getConnection` that reads
|
|
148
|
+
* that tag first, falling back to the pool acquirer only when it is absent
|
|
149
|
+
* (`@effect/sql@0.52` `src/internal/client.ts`). A statement therefore joins
|
|
150
|
+
* a transaction by being RUN inside it, and passing the client along the call
|
|
151
|
+
* chain neither adds to that nor is required by it. `read-postgres`'s
|
|
152
|
+
* `pinnedConnection` test observes the same fact from the SERVER: two
|
|
153
|
+
* statements issued inside one transaction report one `pg_backend_pid()`.
|
|
154
|
+
*
|
|
155
|
+
* Built once at layer construction and re-run per read, which a statement
|
|
156
|
+
* supports: it carries no mutable state and recompiles on every execution.
|
|
157
|
+
*/
|
|
158
|
+
const globalHead: Effect.Effect<Position, SqlError.SqlError> = pooled<{
|
|
159
|
+
readonly max: string | null
|
|
160
|
+
}>`
|
|
161
|
+
SELECT MAX(id) AS max FROM ${pooled(names.mainTable)}
|
|
162
|
+
`.pipe(
|
|
163
|
+
Effect.map((rows) => {
|
|
164
|
+
const max = rows[0]?.max
|
|
165
|
+
return max === null || max === undefined
|
|
166
|
+
? ORIGIN
|
|
167
|
+
: (BigInt(max) as Position)
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The read MECHANICS: the rows a query selects, plus the head a decision
|
|
173
|
+
* model may then append under. `read` below only shapes the pair into a
|
|
174
|
+
* `ReadResponse`, so the two snapshot regimes argued for here stay in one
|
|
175
|
+
* place.
|
|
176
|
+
*
|
|
177
|
+
* Named for what it returns rather than for what it reads through: it closes
|
|
178
|
+
* over `pooled`, and takes no client, for the reason given on `globalHead`.
|
|
179
|
+
*/
|
|
180
|
+
const readRowsAndHead = (
|
|
181
|
+
query: Query,
|
|
182
|
+
options?: ReadOptions,
|
|
183
|
+
): Effect.Effect<
|
|
184
|
+
{ readonly rows: ReadonlyArray<EventRow>; readonly head: Position },
|
|
185
|
+
SqlError.SqlError
|
|
186
|
+
> =>
|
|
187
|
+
Effect.gen(function* () {
|
|
188
|
+
// No validation here — the query and limit were validated once at the
|
|
189
|
+
// `makeContractStore` boundary, so the per-read re-validation is gone.
|
|
190
|
+
const plan = compileFetchPlan(tables, query)
|
|
191
|
+
|
|
192
|
+
// Limited read: `head` is the last RETURNED position, taken from the
|
|
193
|
+
// SAME single statement, so rows and head are already one consistent
|
|
194
|
+
// snapshot (or ORIGIN when nothing is returned). No transaction needed —
|
|
195
|
+
// a single statement sees a single snapshot.
|
|
196
|
+
if (options?.limit !== undefined) {
|
|
197
|
+
const rows = yield* runFetch(
|
|
198
|
+
pooled,
|
|
199
|
+
plan,
|
|
200
|
+
options.after,
|
|
201
|
+
options.limit,
|
|
202
|
+
)
|
|
203
|
+
const lastRow = rows.at(-1)
|
|
204
|
+
const head =
|
|
205
|
+
lastRow === undefined ? ORIGIN : (BigInt(lastRow.id) as Position)
|
|
206
|
+
return { rows, head }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// No-limit read: `head` is the GLOBAL last position (ignoring the query
|
|
210
|
+
// filter), which needs a SEPARATE `MAX(id)` statement. Rows and head
|
|
211
|
+
// MUST share one MVCC snapshot: under the default READ COMMITTED each
|
|
212
|
+
// statement snapshots independently, so a concurrent append committing
|
|
213
|
+
// between the row fetch and the `MAX(id)` would push `head` PAST an
|
|
214
|
+
// event absent from `events` — and a decision model appending under
|
|
215
|
+
// `{ after: head }` would then silently miss that event as a conflict,
|
|
216
|
+
// breaking optimistic concurrency. A REPEATABLE READ, READ ONLY
|
|
217
|
+
// transaction gives both statements a single snapshot, matching the
|
|
218
|
+
// in-memory oracle's atomic (mutex-guarded) read. Readers take only
|
|
219
|
+
// ACCESS SHARE, so this never contends with the exclusive-lock append.
|
|
220
|
+
return yield* pooled.withTransaction(
|
|
221
|
+
Effect.gen(function* () {
|
|
222
|
+
yield* pooled.unsafe(
|
|
223
|
+
'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY',
|
|
224
|
+
)
|
|
225
|
+
const rows = yield* runFetch(
|
|
226
|
+
pooled,
|
|
227
|
+
plan,
|
|
228
|
+
options?.after,
|
|
229
|
+
undefined,
|
|
230
|
+
)
|
|
231
|
+
const head = yield* globalHead
|
|
232
|
+
return { rows, head }
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
const read = (
|
|
238
|
+
query: Query,
|
|
239
|
+
options?: ReadOptions,
|
|
240
|
+
): Effect.Effect<ReadResponse, SqlError.SqlError> =>
|
|
241
|
+
readRowsAndHead(query, options).pipe(
|
|
242
|
+
Effect.map(
|
|
243
|
+
({ rows, head }): ReadResponse => ({
|
|
244
|
+
events: Stream.fromIterable(rows.map(rowToSequenced)),
|
|
245
|
+
head,
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
// --- append -----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* One row of an append function's result: the new head (`MAX(id)`), or NULL /
|
|
254
|
+
* zero rows for the empty/conflict cases the callers interpret.
|
|
255
|
+
*/
|
|
256
|
+
type AppendHeadRow = { readonly max: string | null }
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The ONE transaction frame every append rides: open a transaction, apply the
|
|
260
|
+
* per-call `set_config('lock_timeout', …, true)`, then run the caller's
|
|
261
|
+
* `SELECT <fn>(…)`. Both the conditional and unconditional paths go through
|
|
262
|
+
* here, so the lock-timeout frame can never diverge between them — the
|
|
263
|
+
* function's LOCK/check/insert and the timeout are one atomic unit, and the
|
|
264
|
+
* conflict (zero rows) vs head (`MAX(id)`) is read from the returned rows by
|
|
265
|
+
* the caller, never from a caught error.
|
|
266
|
+
*/
|
|
267
|
+
const callAppendFn = (
|
|
268
|
+
select: Effect.Effect<ReadonlyArray<AppendHeadRow>, SqlError.SqlError>,
|
|
269
|
+
): Effect.Effect<ReadonlyArray<AppendHeadRow>, SqlError.SqlError> =>
|
|
270
|
+
pooled.withTransaction(
|
|
271
|
+
setLocalLockTimeout(pooled, lockTimeout).pipe(Effect.zipRight(select)),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const append = (
|
|
275
|
+
events: Arr.NonEmptyReadonlyArray<DcbEvent>,
|
|
276
|
+
condition?: AppendCondition,
|
|
277
|
+
): Effect.Effect<Position, AppendConditionFailed | SqlError.SqlError> => {
|
|
278
|
+
const eventsJson = events.map(toEventJson)
|
|
279
|
+
|
|
280
|
+
// Raw mechanics: a CONFLICT is `AppendConditionFailed`; a `SqlError` (incl.
|
|
281
|
+
// a `55P03` lock-timeout abort) rides the channel too. `makeContractStore`
|
|
282
|
+
// classifies — conflict stays, everything else becomes a defect.
|
|
283
|
+
return Effect.gen(function* () {
|
|
284
|
+
if (condition === undefined) {
|
|
285
|
+
// No guard, but the unconditional twin STILL takes the exclusive lock
|
|
286
|
+
// (see `unconditionalAppendBody`). That lock on BOTH append paths is how
|
|
287
|
+
// this engine discharges the subscribe machine's ascending-commit-order
|
|
288
|
+
// precondition (`SubscribeMachine.ts` argues it): without it two
|
|
289
|
+
// concurrent unconditional writers can commit bigserial ids out of
|
|
290
|
+
// order, letting `subscribe` skip the later-committing lower id and
|
|
291
|
+
// `head` overtake an in-flight position. Sharing `callAppendFn` with the
|
|
292
|
+
// conditional path guarantees the lock is scoped to the call and the
|
|
293
|
+
// configured timeout applies identically to both append paths.
|
|
294
|
+
const rows = yield* callAppendFn(
|
|
295
|
+
pooled<AppendHeadRow>`
|
|
296
|
+
SELECT ${pooled(names.appendUnconditionalFn)}(
|
|
297
|
+
${jsonbText(eventsJson)}::jsonb
|
|
298
|
+
) AS max
|
|
299
|
+
`,
|
|
300
|
+
)
|
|
301
|
+
return headFromRows(rows)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const queryItems = toQueryItemsJson(condition.failIfEventsMatch)
|
|
305
|
+
const afterId = condition.after === undefined ? null : condition.after
|
|
306
|
+
|
|
307
|
+
// The function returns MAX(id) on success, ZERO rows on conflict — the
|
|
308
|
+
// conflict is the row count, never a caught error.
|
|
309
|
+
const rows = yield* callAppendFn(
|
|
310
|
+
pooled<AppendHeadRow>`
|
|
311
|
+
SELECT ${pooled(names.appendFn)}(
|
|
312
|
+
${jsonbText(queryItems)}::jsonb,
|
|
313
|
+
${afterId},
|
|
314
|
+
${jsonbText(eventsJson)}::jsonb
|
|
315
|
+
) AS max
|
|
316
|
+
`,
|
|
317
|
+
)
|
|
318
|
+
// Empty result set = conflict.
|
|
319
|
+
if (rows.length === 0) {
|
|
320
|
+
return yield* new AppendConditionFailed({ condition })
|
|
321
|
+
}
|
|
322
|
+
return headFromRows(rows)
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Extract the new head `Position` from the function's single result row. The
|
|
328
|
+
* function returns exactly one row (`MAX(id)`) on a successful insert; that
|
|
329
|
+
* value is the last new position (read-your-writes).
|
|
330
|
+
*/
|
|
331
|
+
const headFromRows = (rows: ReadonlyArray<AppendHeadRow>): Position => {
|
|
332
|
+
const max = rows[0]?.max
|
|
333
|
+
if (max === null || max === undefined) {
|
|
334
|
+
// Defensive: a successful unconditional/`NOT conflict` insert always
|
|
335
|
+
// yields a non-null MAX. A null here means nothing was inserted, which
|
|
336
|
+
// for an append (always ≥1 event) is a broken invariant → die.
|
|
337
|
+
throw new Error(
|
|
338
|
+
'kairos-es/store-postgres: append returned no head position',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
return BigInt(max) as Position
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- subscribe --------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Wire core's subscribe machine to the live clients: `fetch` is the
|
|
348
|
+
* per-subscription compiled read plan run against the pooled client and mapped
|
|
349
|
+
* to `SequencedEvent`s; `listen` is the direct client's ref-counted, non-pooled
|
|
350
|
+
* LISTEN on this store's channel (the Neon-safe path — `clients.ts` owns why
|
|
351
|
+
* there are two clients at all). The plan is compiled ONCE here so the poll
|
|
352
|
+
* loop re-binds only the cursor.
|
|
353
|
+
*
|
|
354
|
+
* The wake source is `NOTIFY`, and it is a LATENCY optimisation only: the
|
|
355
|
+
* machine's periodic position-based poll is what makes delivery correct, which
|
|
356
|
+
* matters more here than the general argument for it suggests. The
|
|
357
|
+
* `@effect/sql-pg` listen client SWALLOWS connection errors and does not
|
|
358
|
+
* auto-reconnect, so a dropped LISTEN typically goes quiet rather than
|
|
359
|
+
* erroring — the machine's listen-side retry then never fires, and the poll is
|
|
360
|
+
* the ONLY thing that recovers the subscription. The same silence is what a
|
|
361
|
+
* Neon pooled endpoint produces if `PgClientDirect` is misconfigured: the
|
|
362
|
+
* pooler drops `LISTEN`/`NOTIFY` without complaint and delivery degrades to
|
|
363
|
+
* poll-rate rather than failing, which is why that trap is a deployment-time
|
|
364
|
+
* wiring choice and not a runtime error.
|
|
365
|
+
*
|
|
366
|
+
* `E` is `SqlError.SqlError` — both seams fail with it, so the machine's single
|
|
367
|
+
* error parameter carries it straight through to `makeContractStore`.
|
|
368
|
+
*/
|
|
369
|
+
const subscribe = (
|
|
370
|
+
query: Query,
|
|
371
|
+
after?: Position,
|
|
372
|
+
): Stream.Stream<SequencedEvent, SqlError.SqlError, Scope.Scope> => {
|
|
373
|
+
const plan = compileFetchPlan(tables, query)
|
|
374
|
+
return subscribeStream<SqlError.SqlError>(
|
|
375
|
+
{
|
|
376
|
+
fetch: (from, limit) =>
|
|
377
|
+
runFetch(pooled, plan, from, limit).pipe(
|
|
378
|
+
Effect.map((rows) => rows.map(rowToSequenced)),
|
|
379
|
+
),
|
|
380
|
+
listen: direct.listen(names.channel),
|
|
381
|
+
pollInterval,
|
|
382
|
+
catchUpPageSize,
|
|
383
|
+
},
|
|
384
|
+
after,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { read, append, subscribe } as const
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The Postgres `DcbEventStore` layer factory. Builds the engine over
|
|
393
|
+
* `PgClientPooled` (reads/append/notify) and `PgClientDirect` (the subscribe
|
|
394
|
+
* listener). The caller wires those two client layers — pointing `PgClientDirect`
|
|
395
|
+
* at a direct (non-pooler) endpoint on Neon, or at the same URL as `PgClientPooled`
|
|
396
|
+
* everywhere else.
|
|
397
|
+
*
|
|
398
|
+
* `ensureSchema(config)` must have been run (by the caller's migrator or at
|
|
399
|
+
* boot) before the layer is used; the layer itself performs no DDL, so it stays
|
|
400
|
+
* a pure runtime dependency requiring only the two clients.
|
|
401
|
+
*/
|
|
402
|
+
export const layer = (
|
|
403
|
+
config: PostgresStoreConfig = {},
|
|
404
|
+
): Layer.Layer<DcbEventStore, never, PgClientPooled | PgClientDirect> => {
|
|
405
|
+
// Decode through the config schema so a malformed config (empty/dotted atom,
|
|
406
|
+
// negative/non-finite timeout, over-63-byte derived name) fails LOUDLY at
|
|
407
|
+
// construction rather than late on the first append.
|
|
408
|
+
const decoded = decodePostgresStoreConfig(config)
|
|
409
|
+
return Layer.effect(
|
|
410
|
+
DcbEventStore,
|
|
411
|
+
Effect.map(make(decoded, resolveNames(decoded)), makeContractStore),
|
|
412
|
+
)
|
|
413
|
+
}
|