@kairos-es/read-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 +238 -0
- package/dist/cjs/config.js +86 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureCheckpointSchema.js +58 -0
- package/dist/cjs/ensureCheckpointSchema.js.map +1 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/ddl.js +172 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/statements.js +83 -0
- package/dist/cjs/internal/statements.js.map +1 -0
- package/dist/cjs/projectionStore.js +154 -0
- package/dist/cjs/projectionStore.js.map +1 -0
- package/dist/dts/config.d.ts +65 -0
- package/dist/dts/config.d.ts.map +1 -0
- package/dist/dts/ensureCheckpointSchema.d.ts +39 -0
- package/dist/dts/ensureCheckpointSchema.d.ts.map +1 -0
- package/dist/dts/index.d.ts +90 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/ddl.d.ts +146 -0
- package/dist/dts/internal/ddl.d.ts.map +1 -0
- package/dist/dts/internal/statements.d.ts +100 -0
- package/dist/dts/internal/statements.d.ts.map +1 -0
- package/dist/dts/projectionStore.d.ts +30 -0
- package/dist/dts/projectionStore.d.ts.map +1 -0
- package/dist/esm/config.js +79 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureCheckpointSchema.js +50 -0
- package/dist/esm/ensureCheckpointSchema.js.map +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ddl.js +161 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/statements.js +74 -0
- package/dist/esm/internal/statements.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/projectionStore.js +147 -0
- package/dist/esm/projectionStore.js.map +1 -0
- package/package.json +33 -0
- package/src/config.ts +83 -0
- package/src/ensureCheckpointSchema.ts +51 -0
- package/src/index.ts +89 -0
- package/src/internal/ddl.ts +246 -0
- package/src/internal/statements.ts +144 -0
- package/src/projectionStore.ts +300 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four runtime statement texts the Postgres `ProjectionStore` issues, and the
|
|
3
|
+
* TWO parameter lists that feed them.
|
|
4
|
+
*
|
|
5
|
+
* The texts and their binding live in ONE module deliberately. `$3` and `$4` of
|
|
6
|
+
* the guarded advance are `next` and `expected` — both a `Position`, so both a
|
|
7
|
+
* `bigint` — and the whole exactly-once property of the read side rests on which
|
|
8
|
+
* of them the `SET` writes and which the `WHERE` compares. While the texts sat
|
|
9
|
+
* beside the store's Effect wiring and the binding sat inside its closure, that
|
|
10
|
+
* correspondence was asserted in prose in one place and implemented 180 lines away
|
|
11
|
+
* in another, and prose is exactly what drifted: a comment claimed a `bind` helper
|
|
12
|
+
* made a transposed argument list inexpressible, while the call site spread two
|
|
13
|
+
* interchangeable `Position`s positionally. Here the claim is checkable by reading
|
|
14
|
+
* one file, and `test/guardParameters.test.ts` pins it without a database.
|
|
15
|
+
*
|
|
16
|
+
* Everything else follows `internal/ddl.ts`: identifiers are pre-quoted literal
|
|
17
|
+
* text (`quoteQualified` / `CHECKPOINT_COLUMNS`) embedded into `sql.unsafe`, and
|
|
18
|
+
* every VALUE is a bound `$n` parameter, never interpolated.
|
|
19
|
+
*/
|
|
20
|
+
import type { Position } from '@kairos-es/core'
|
|
21
|
+
import type { CheckpointKey } from '@kairos-es/read'
|
|
22
|
+
import {
|
|
23
|
+
CHECKPOINT_COLUMNS,
|
|
24
|
+
quoteQualified,
|
|
25
|
+
type ResolvedCheckpointNames,
|
|
26
|
+
} from './ddl'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The four statement texts a store issues, compiled ONCE per store from the
|
|
30
|
+
* resolved names — the same compile-once discipline as the event store's read
|
|
31
|
+
* plans, so a long-lived projection re-binds only its parameters rather than
|
|
32
|
+
* re-assembling SQL per batch.
|
|
33
|
+
*
|
|
34
|
+
* All four take their parameters in ONE positional order — `$1` projection,
|
|
35
|
+
* `$2` partition, `$3` next, `$4` expected — and no consumer writes that order
|
|
36
|
+
* out: `keyParams` binds the two the `select` and the `reset` take, and
|
|
37
|
+
* `advanceParams` binds all four for the two guard shapes, reading each from a
|
|
38
|
+
* NAMED field. That naming is the point rather than a nicety, because the type
|
|
39
|
+
* system cannot help here: `next` and `expected` are the same type, so a
|
|
40
|
+
* positional list makes `[..., expected, next]` typecheck, and a transposed guard
|
|
41
|
+
* would compare the stored cursor against the position it was about to write —
|
|
42
|
+
* inverting the compare-and-set that makes a projection exactly-once. Reading the
|
|
43
|
+
* fields by name does not make the mistake impossible, but it makes it
|
|
44
|
+
* unwritable without naming the wrong field, which review and a diff can see.
|
|
45
|
+
*/
|
|
46
|
+
export interface CheckpointStatements {
|
|
47
|
+
readonly select: string
|
|
48
|
+
readonly upsertFromOrigin: string
|
|
49
|
+
readonly advance: string
|
|
50
|
+
readonly reset: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the statement texts for a resolved name set.
|
|
55
|
+
*
|
|
56
|
+
* **The two guard shapes, and why one cannot serve both.** The guarded advance is
|
|
57
|
+
* a compare-and-set on `last_position`, and its correct SQL depends on whether the
|
|
58
|
+
* expected value is `ORIGIN`:
|
|
59
|
+
*
|
|
60
|
+
* - `expected === ORIGIN` needs `upsertFromOrigin`. A never-committed key has NO
|
|
61
|
+
* ROW (a key reads back as `ORIGIN` by absence, so "fresh" and "reset" are one
|
|
62
|
+
* state), so a plain `UPDATE` would match zero rows and report
|
|
63
|
+
* `CheckpointSuperseded` — a fresh read model could never make its FIRST commit.
|
|
64
|
+
* The `ON CONFLICT … DO UPDATE … WHERE chk.last_position = $4` arm keeps the
|
|
65
|
+
* upsert a real guard rather than a blind write: two racers both starting from
|
|
66
|
+
* `ORIGIN` cannot both win, because the second one's arm blocks on the first's
|
|
67
|
+
* uncommitted row, then re-reads it and finds a position that is no longer
|
|
68
|
+
* `ORIGIN`, matching zero rows. That arm also makes "row present at `0`"
|
|
69
|
+
* (an operator zeroing the column by hand instead of deleting the row) behave
|
|
70
|
+
* identically to "row absent", so the two ways of expressing a reset are
|
|
71
|
+
* indistinguishable to a first commit.
|
|
72
|
+
* - `expected > ORIGIN` needs `advance`, the plain `UPDATE`. The upsert would be
|
|
73
|
+
* WRONG here: `ON CONFLICT` guards only the branch where the row exists, so an
|
|
74
|
+
* absent row would be INSERTED unconditionally — a `resetCheckpoint` racing an
|
|
75
|
+
* in-flight commit would be silently undone, the key resurrected at `next`, and
|
|
76
|
+
* the entire backlog the reset existed to replay skipped with no error at all.
|
|
77
|
+
* The `UPDATE`'s zero rows for an absent row is the CORRECT outcome: somebody
|
|
78
|
+
* reset it, so this commit must lose and the runner must re-read.
|
|
79
|
+
*
|
|
80
|
+
* Both shapes `RETURNING` a column so the outcome is a ROW COUNT: zero rows means
|
|
81
|
+
* the guard lost. That mirrors the event store's append, where a conflict is the
|
|
82
|
+
* function's zero-row result and never a caught SQL error — no unique-violation
|
|
83
|
+
* or serialisation-failure path to classify, so a lost guard can never arrive
|
|
84
|
+
* mistagged as an infrastructure fault.
|
|
85
|
+
*
|
|
86
|
+
* The `AS chk` alias exists so the `DO UPDATE` guard can reference the EXISTING
|
|
87
|
+
* row unambiguously: the insert target is schema-qualified, and without an alias
|
|
88
|
+
* the guard would need a three-part column reference.
|
|
89
|
+
*/
|
|
90
|
+
export const compileStatements = (
|
|
91
|
+
names: ResolvedCheckpointNames,
|
|
92
|
+
): CheckpointStatements => {
|
|
93
|
+
const table = quoteQualified(names.checkpointTable)
|
|
94
|
+
const columns = CHECKPOINT_COLUMNS
|
|
95
|
+
const byKey = `${columns.projection} = $1 AND ${columns.partition} = $2`
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
select: `SELECT ${columns.lastPosition}
|
|
99
|
+
FROM ${table}
|
|
100
|
+
WHERE ${byKey}`,
|
|
101
|
+
|
|
102
|
+
upsertFromOrigin: `INSERT INTO ${table} AS chk (
|
|
103
|
+
${columns.projection},
|
|
104
|
+
${columns.partition},
|
|
105
|
+
${columns.lastPosition}
|
|
106
|
+
) VALUES ($1, $2, $3)
|
|
107
|
+
ON CONFLICT (${columns.projection}, ${columns.partition})
|
|
108
|
+
DO UPDATE SET ${columns.lastPosition} = $3
|
|
109
|
+
WHERE chk.${columns.lastPosition} = $4
|
|
110
|
+
RETURNING ${columns.lastPosition}`,
|
|
111
|
+
|
|
112
|
+
advance: `UPDATE ${table}
|
|
113
|
+
SET ${columns.lastPosition} = $3
|
|
114
|
+
WHERE ${byKey} AND ${columns.lastPosition} = $4
|
|
115
|
+
RETURNING ${columns.lastPosition}`,
|
|
116
|
+
|
|
117
|
+
reset: `DELETE FROM ${table} WHERE ${byKey}`,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The `$1`/`$2` key parameters the `select` and the `reset` take. */
|
|
122
|
+
export const keyParams = (key: CheckpointKey): ReadonlyArray<unknown> => [
|
|
123
|
+
key.projection,
|
|
124
|
+
key.partition,
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The `$1`-`$4` parameters both guard shapes take, read from named fields.
|
|
129
|
+
*
|
|
130
|
+
* The argument is an object rather than three positional parameters for the reason
|
|
131
|
+
* `CheckpointStatements` gives: `next` and `expected` are interchangeable to the
|
|
132
|
+
* type checker and catastrophic to swap, so the ONE place their order is written
|
|
133
|
+
* out reads each of them by name.
|
|
134
|
+
*/
|
|
135
|
+
export const advanceParams = (args: {
|
|
136
|
+
readonly key: CheckpointKey
|
|
137
|
+
readonly expected: Position
|
|
138
|
+
readonly next: Position
|
|
139
|
+
}): ReadonlyArray<unknown> => [
|
|
140
|
+
args.key.projection,
|
|
141
|
+
args.key.partition,
|
|
142
|
+
args.next,
|
|
143
|
+
args.expected,
|
|
144
|
+
]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL `ProjectionStore`: a durable scalar checkpoint plus the guarded
|
|
3
|
+
* compare-and-set that brackets a read model's own view writes in ONE ordinary
|
|
4
|
+
* transaction (ADR-0007).
|
|
5
|
+
*
|
|
6
|
+
* This is the durable half of the parity pair. `@kairos-es/read`'s in-memory store
|
|
7
|
+
* is the oracle; this backend must produce identical OBSERVABLE behaviour, and the
|
|
8
|
+
* shared `projection-store-contract-tests` suite runs unchanged against both. The
|
|
9
|
+
* one place the two genuinely differ is HOW the abort is achieved:
|
|
10
|
+
*
|
|
11
|
+
* - in memory, the guard is checked FIRST and a loss means `viewWrites` never ran
|
|
12
|
+
* (abort by omission — there is nothing to undo);
|
|
13
|
+
* - here, `viewWrites` runs first and a lost guard FAILS, so the transaction rolls
|
|
14
|
+
* the view writes back with it (abort by undo).
|
|
15
|
+
*
|
|
16
|
+
* Both satisfy the port's promise that a lost guard leaves no observable view
|
|
17
|
+
* effect, which is why ordering inside the transaction is a performance choice
|
|
18
|
+
* rather than a correctness one: running the guard last keeps the checkpoint row
|
|
19
|
+
* lock held only for the advance instead of for the whole batch's worth of view
|
|
20
|
+
* writes.
|
|
21
|
+
*
|
|
22
|
+
* Two contract inversions worth naming, both deliberate:
|
|
23
|
+
*
|
|
24
|
+
* 1. A `SqlError` becomes a `ProjectionStoreError` on the ERROR CHANNEL — the
|
|
25
|
+
* opposite of the event store, where an infrastructure fault is a DEFECT
|
|
26
|
+
* (`Effect.die`, ADR-0002). The read side has a supervisor above it whose whole
|
|
27
|
+
* job is telling "retry the subscription with backoff" from "give up and report
|
|
28
|
+
* a stalled projection", and a defect carries no such distinction. On the event
|
|
29
|
+
* store there is no supervisor: a caller cannot meaningfully retry a broken
|
|
30
|
+
* connection, so dying is right there and wrong here.
|
|
31
|
+
* 2. A lost guard is a tagged failure (`CheckpointSuperseded`), never a returned
|
|
32
|
+
* outcome value, and that failure is exactly what aborts the transaction. A
|
|
33
|
+
* returned value would only roll back if the caller remembered to convert it
|
|
34
|
+
* into a failure, and forgetting is silent double-application.
|
|
35
|
+
*
|
|
36
|
+
* The store closes over the `SqlClient` it acquired, so the returned
|
|
37
|
+
* `ProjectionStore.commit` adds NO `SqlClient` to the caller's `R` — a read model
|
|
38
|
+
* stays free of the view store's requirements, which is what lets one runner drive
|
|
39
|
+
* read models over different backends.
|
|
40
|
+
*
|
|
41
|
+
* What this module owns is the Effect wiring: the client, the transaction bracket,
|
|
42
|
+
* the error classification. The SQL itself and the parameter lists that feed it are
|
|
43
|
+
* `internal/statements.ts`, which owns the argument for why the two live together.
|
|
44
|
+
*/
|
|
45
|
+
import type { SqlError } from '@effect/sql'
|
|
46
|
+
import { SqlClient } from '@effect/sql'
|
|
47
|
+
import { ORIGIN, Position } from '@kairos-es/core'
|
|
48
|
+
import {
|
|
49
|
+
type CheckpointKey,
|
|
50
|
+
CheckpointSuperseded,
|
|
51
|
+
type ProjectionStore,
|
|
52
|
+
ProjectionStoreError,
|
|
53
|
+
} from '@kairos-es/read'
|
|
54
|
+
import { Effect } from 'effect'
|
|
55
|
+
import { decodeReadPostgresConfig, type ReadPostgresConfig } from './config'
|
|
56
|
+
import { resolveCheckpointNames } from './internal/ddl'
|
|
57
|
+
import {
|
|
58
|
+
advanceParams,
|
|
59
|
+
compileStatements,
|
|
60
|
+
keyParams,
|
|
61
|
+
} from './internal/statements'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A row of the checkpoint table's only interesting column.
|
|
65
|
+
*
|
|
66
|
+
* `last_position` is typed as a STRING because `bigint`/`int8` arrives from the
|
|
67
|
+
* driver as a decimal string: a 64-bit position exceeds JS `number` safety at
|
|
68
|
+
* 2^53, so it is reconstructed with `BigInt(...)` and never coerced through
|
|
69
|
+
* `number` — the same round-trip the event store performs for its `MAX(id)` head.
|
|
70
|
+
* The snake_case key is the column name verbatim; see `withoutTransforms()` below
|
|
71
|
+
* for why a consumer's result-name transform cannot rename it under us.
|
|
72
|
+
*/
|
|
73
|
+
type LastPositionRow = { readonly last_position: string }
|
|
74
|
+
|
|
75
|
+
/** A statement returning no columns (a `DELETE` with no `RETURNING`). */
|
|
76
|
+
type NoRows = Record<string, never>
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The caller's `viewWrites` failure, wrapped for the duration of the transaction.
|
|
80
|
+
*
|
|
81
|
+
* `sql.withTransaction` ADDS `SqlError` to whatever its body fails with, so the
|
|
82
|
+
* frame's own faults (BEGIN/SAVEPOINT/COMMIT) arrive in the same channel as the
|
|
83
|
+
* caller's `E` — and if a read model's view writes are themselves SQL, that `E`
|
|
84
|
+
* IS `SqlError`, making the two indistinguishable. Wrapping `E` before entering
|
|
85
|
+
* the transaction and unwrapping it after keeps them apart, so the port's promise
|
|
86
|
+
* that a projection's own `E` passes through `commit` UNTOUCHED holds exactly,
|
|
87
|
+
* while a genuine transaction-frame fault still becomes a `ProjectionStoreError`.
|
|
88
|
+
* A plain tagged object rather than a `Data.TaggedError` because it never escapes
|
|
89
|
+
* this module.
|
|
90
|
+
*/
|
|
91
|
+
interface ViewWriteFailed<E> {
|
|
92
|
+
readonly _tag: 'ViewWriteFailed'
|
|
93
|
+
readonly error: E
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Every failure `sql.withTransaction` can hand back for a `commit` body. */
|
|
97
|
+
type CommitFailure<E> =
|
|
98
|
+
| ViewWriteFailed<E>
|
|
99
|
+
| CheckpointSuperseded
|
|
100
|
+
| ProjectionStoreError
|
|
101
|
+
| SqlError.SqlError
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Turn a `ProjectionStoreError` out of a SQL fault, naming what was being
|
|
105
|
+
* attempted and for which key. `reason` is the human-readable line a supervisor
|
|
106
|
+
* logs, so it carries the key and the driver's message; `cause` carries the
|
|
107
|
+
* `SqlError` itself for anything that wants to inspect it.
|
|
108
|
+
*/
|
|
109
|
+
const storeError =
|
|
110
|
+
(attempt: string, key: CheckpointKey) =>
|
|
111
|
+
(cause: SqlError.SqlError): ProjectionStoreError =>
|
|
112
|
+
new ProjectionStoreError({
|
|
113
|
+
reason: `${attempt} for projection '${key.projection}' partition '${key.partition}' failed: ${cause.message}`,
|
|
114
|
+
cause,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Restore the port's declared error channel after the transaction: unwrap the
|
|
119
|
+
* caller's `E`, and classify a transaction-frame `SqlError` as infrastructure.
|
|
120
|
+
* Both `CheckpointSuperseded` and an already-classified `ProjectionStoreError`
|
|
121
|
+
* pass through as they are.
|
|
122
|
+
*/
|
|
123
|
+
const unwrapCommitError = <E>(
|
|
124
|
+
key: CheckpointKey,
|
|
125
|
+
error: CommitFailure<E>,
|
|
126
|
+
): E | CheckpointSuperseded | ProjectionStoreError => {
|
|
127
|
+
if (error._tag === 'ViewWriteFailed') {
|
|
128
|
+
return error.error
|
|
129
|
+
}
|
|
130
|
+
if (error._tag === 'SqlError') {
|
|
131
|
+
return storeError('the checkpoint commit transaction', key)(error)
|
|
132
|
+
}
|
|
133
|
+
return error
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build a durable `ProjectionStore` over the ambient `SqlClient`.
|
|
138
|
+
*
|
|
139
|
+
* A factory `Effect` rather than a `Layer`, matching the in-memory store: the view
|
|
140
|
+
* store is selected PER MATERIALISATION, so it is a value handed to a read model, not
|
|
141
|
+
* a context tag one store could claim process-wide. Per materialisation rather than
|
|
142
|
+
* per read model, because one read model may be materialised several times at once —
|
|
143
|
+
* a store from here beside an in-memory one, wired by `@kairos-es/read`'s
|
|
144
|
+
* `runProjections` — which is a shape a process-wide tag could not express at all. It
|
|
145
|
+
* acquires the client ONCE and closes over it, which is what keeps `commit`'s `R`
|
|
146
|
+
* down to the caller's own.
|
|
147
|
+
*
|
|
148
|
+
* **The atomic bracket covers writes issued through THIS client.** The caller's
|
|
149
|
+
* `viewWrites` join the transaction because `@effect/sql` carries the transaction's
|
|
150
|
+
* connection in the Effect CONTEXT, not in the client value — so any statement run
|
|
151
|
+
* through the same underlying client inside `commit` is in the same transaction,
|
|
152
|
+
* including through a differently-transformed copy of it. A write to a different
|
|
153
|
+
* database, or through an unrelated client, is NOT bracketed and the exactly-once
|
|
154
|
+
* property does not extend to it.
|
|
155
|
+
*
|
|
156
|
+
* `ensureCheckpointSchema(config)` must have run (in the caller's migrator or at
|
|
157
|
+
* boot) before the store is used; this factory performs no DDL, so it stays a pure
|
|
158
|
+
* runtime dependency on the client alone.
|
|
159
|
+
*/
|
|
160
|
+
export const makeSqlProjectionStore = (
|
|
161
|
+
config: ReadPostgresConfig = {},
|
|
162
|
+
): Effect.Effect<ProjectionStore, never, SqlClient.SqlClient> =>
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
// `withoutTransforms()` because this package takes the CONSUMER's generic
|
|
165
|
+
// client, which may carry a result-name transform (`@effect/sql` will happily
|
|
166
|
+
// camel-case every returned key). Under one, `last_position` would arrive as
|
|
167
|
+
// `lastPosition`, `readCheckpoint` would see `undefined` and report `ORIGIN`,
|
|
168
|
+
// and a durable projection would silently replay its whole backlog on every
|
|
169
|
+
// boot. Stripping the transform for OUR statements is the fix; the caller's
|
|
170
|
+
// own view writes keep their client's transforms and still join the same
|
|
171
|
+
// transaction (the connection travels in the context, not the client).
|
|
172
|
+
const sql = (yield* SqlClient.SqlClient).withoutTransforms()
|
|
173
|
+
|
|
174
|
+
// Decode INSIDE the Effect so a malformed config (empty/dotted atom, an
|
|
175
|
+
// over-63-byte derived name) surfaces as a defect on the fibre building the
|
|
176
|
+
// store rather than as an exception thrown out of the factory call, matching
|
|
177
|
+
// `ensureCheckpointSchema`.
|
|
178
|
+
const names = resolveCheckpointNames(decodeReadPostgresConfig(config))
|
|
179
|
+
const statements = compileStatements(names)
|
|
180
|
+
|
|
181
|
+
const readCheckpoint = (
|
|
182
|
+
key: CheckpointKey,
|
|
183
|
+
): Effect.Effect<Position, ProjectionStoreError> =>
|
|
184
|
+
sql.unsafe<LastPositionRow>(statements.select, keyParams(key)).pipe(
|
|
185
|
+
Effect.mapError(storeError('reading the checkpoint', key)),
|
|
186
|
+
Effect.map((rows) => {
|
|
187
|
+
const stored = rows[0]?.last_position
|
|
188
|
+
// No row means never committed (or reset), which the port defines as
|
|
189
|
+
// `ORIGIN` — so a fresh read model and a reset one both simply catch up
|
|
190
|
+
// from the start of the log.
|
|
191
|
+
return stored === undefined
|
|
192
|
+
? ORIGIN
|
|
193
|
+
: // `Position.make` re-validates non-negativity, so a corrupted
|
|
194
|
+
// column becomes a loud defect here rather than a nonsensical
|
|
195
|
+
// cursor that silently reads nothing.
|
|
196
|
+
Position.make(BigInt(stored))
|
|
197
|
+
}),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The compare-and-set half of `commit`, run INSIDE the caller's transaction.
|
|
202
|
+
* Which shape is correct depends on `expected`; see `compileStatements` for
|
|
203
|
+
* why neither shape can serve both cases.
|
|
204
|
+
*
|
|
205
|
+
* Takes its three values as NAMED FIELDS, and `commit` hands it the very
|
|
206
|
+
* object the port handed `commit`, so `expected` and `next` are never lined up
|
|
207
|
+
* positionally anywhere between the port's boundary and the bound `$n`
|
|
208
|
+
* (`advanceParams` does the binding, also by name). They are both a `Position`,
|
|
209
|
+
* so nothing else would notice them swapping — and swapped, the guard would
|
|
210
|
+
* compare the stored cursor against the position it was about to write.
|
|
211
|
+
*/
|
|
212
|
+
const guardedAdvance = (args: {
|
|
213
|
+
readonly key: CheckpointKey
|
|
214
|
+
readonly expected: Position
|
|
215
|
+
readonly next: Position
|
|
216
|
+
}): Effect.Effect<void, CheckpointSuperseded | ProjectionStoreError> => {
|
|
217
|
+
const { key, expected } = args
|
|
218
|
+
const [text, attempt] =
|
|
219
|
+
expected === ORIGIN
|
|
220
|
+
? ([
|
|
221
|
+
statements.upsertFromOrigin,
|
|
222
|
+
'inserting the first checkpoint',
|
|
223
|
+
] as const)
|
|
224
|
+
: ([statements.advance, 'advancing the checkpoint'] as const)
|
|
225
|
+
// `next` is bound AS IS — it is the last processed event's position, never
|
|
226
|
+
// `expected + 1`: positions are strictly increasing but not gapless, so a
|
|
227
|
+
// jump is normal and arithmetic would be wrong.
|
|
228
|
+
return sql.unsafe<LastPositionRow>(text, advanceParams(args)).pipe(
|
|
229
|
+
Effect.mapError(storeError(attempt, key)),
|
|
230
|
+
Effect.flatMap((rows) =>
|
|
231
|
+
// Zero rows = the guard lost. Failing (rather than returning an
|
|
232
|
+
// outcome) is what aborts the enclosing transaction and rolls the
|
|
233
|
+
// view writes back WITH it.
|
|
234
|
+
rows.length === 0
|
|
235
|
+
? Effect.fail(new CheckpointSuperseded({ key, expected }))
|
|
236
|
+
: Effect.void,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const commit = <E, R>(args: {
|
|
242
|
+
readonly key: CheckpointKey
|
|
243
|
+
readonly expected: Position
|
|
244
|
+
readonly next: Position
|
|
245
|
+
readonly viewWrites: Effect.Effect<void, E, R>
|
|
246
|
+
}): Effect.Effect<
|
|
247
|
+
void,
|
|
248
|
+
E | CheckpointSuperseded | ProjectionStoreError,
|
|
249
|
+
R
|
|
250
|
+
> =>
|
|
251
|
+
sql
|
|
252
|
+
.withTransaction(
|
|
253
|
+
args.viewWrites.pipe(
|
|
254
|
+
Effect.mapError(
|
|
255
|
+
(error): ViewWriteFailed<E> => ({
|
|
256
|
+
_tag: 'ViewWriteFailed',
|
|
257
|
+
error,
|
|
258
|
+
}),
|
|
259
|
+
),
|
|
260
|
+
// View writes first, guard last. Ordering is a performance choice,
|
|
261
|
+
// not a correctness one: the transaction makes the pair atomic
|
|
262
|
+
// either way, and deferring the advance holds the checkpoint row
|
|
263
|
+
// lock only for the advance rather than across the whole batch. (The
|
|
264
|
+
// in-memory store has no such freedom — with nothing to roll back it
|
|
265
|
+
// MUST check first.) No `uninterruptible` wrapper either:
|
|
266
|
+
// `withTransaction` masks interruption around its own frame, so an
|
|
267
|
+
// interrupt landing mid-body rolls back exactly like a failure.
|
|
268
|
+
// `args` itself, not three values pulled out of it in an order: see
|
|
269
|
+
// `guardedAdvance`.
|
|
270
|
+
Effect.zipRight(guardedAdvance(args)),
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
.pipe(Effect.mapError((error) => unwrapCommitError<E>(args.key, error)))
|
|
274
|
+
|
|
275
|
+
const resetCheckpoint = (
|
|
276
|
+
key: CheckpointKey,
|
|
277
|
+
): Effect.Effect<void, ProjectionStoreError> =>
|
|
278
|
+
// DELETE rather than `SET last_position = 0`, so "never committed" and
|
|
279
|
+
// "reset" stay ONE state — which is exactly what the port promises the
|
|
280
|
+
// read-back is, and what the in-memory store's map delete does.
|
|
281
|
+
sql
|
|
282
|
+
.unsafe<NoRows>(statements.reset, keyParams(key))
|
|
283
|
+
.pipe(
|
|
284
|
+
Effect.mapError(storeError('resetting the checkpoint', key)),
|
|
285
|
+
Effect.asVoid,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// Annotated at the definition site rather than only through the exported
|
|
289
|
+
// signature, per the rule on `ProjectionStore` itself.
|
|
290
|
+
const store: ProjectionStore = {
|
|
291
|
+
// Outlives the process, which is the R2 input: a durable view is legal only
|
|
292
|
+
// over a durable log, and the runner dies at construction if it is wired
|
|
293
|
+
// over an ephemeral one.
|
|
294
|
+
durability: 'durable',
|
|
295
|
+
readCheckpoint,
|
|
296
|
+
commit,
|
|
297
|
+
resetCheckpoint,
|
|
298
|
+
}
|
|
299
|
+
return store
|
|
300
|
+
})
|