@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.
Files changed (46) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +238 -0
  3. package/dist/cjs/config.js +86 -0
  4. package/dist/cjs/config.js.map +1 -0
  5. package/dist/cjs/ensureCheckpointSchema.js +58 -0
  6. package/dist/cjs/ensureCheckpointSchema.js.map +1 -0
  7. package/dist/cjs/index.js +20 -0
  8. package/dist/cjs/index.js.map +1 -0
  9. package/dist/cjs/internal/ddl.js +172 -0
  10. package/dist/cjs/internal/ddl.js.map +1 -0
  11. package/dist/cjs/internal/statements.js +83 -0
  12. package/dist/cjs/internal/statements.js.map +1 -0
  13. package/dist/cjs/projectionStore.js +154 -0
  14. package/dist/cjs/projectionStore.js.map +1 -0
  15. package/dist/dts/config.d.ts +65 -0
  16. package/dist/dts/config.d.ts.map +1 -0
  17. package/dist/dts/ensureCheckpointSchema.d.ts +39 -0
  18. package/dist/dts/ensureCheckpointSchema.d.ts.map +1 -0
  19. package/dist/dts/index.d.ts +90 -0
  20. package/dist/dts/index.d.ts.map +1 -0
  21. package/dist/dts/internal/ddl.d.ts +146 -0
  22. package/dist/dts/internal/ddl.d.ts.map +1 -0
  23. package/dist/dts/internal/statements.d.ts +100 -0
  24. package/dist/dts/internal/statements.d.ts.map +1 -0
  25. package/dist/dts/projectionStore.d.ts +30 -0
  26. package/dist/dts/projectionStore.d.ts.map +1 -0
  27. package/dist/esm/config.js +79 -0
  28. package/dist/esm/config.js.map +1 -0
  29. package/dist/esm/ensureCheckpointSchema.js +50 -0
  30. package/dist/esm/ensureCheckpointSchema.js.map +1 -0
  31. package/dist/esm/index.js +3 -0
  32. package/dist/esm/index.js.map +1 -0
  33. package/dist/esm/internal/ddl.js +161 -0
  34. package/dist/esm/internal/ddl.js.map +1 -0
  35. package/dist/esm/internal/statements.js +74 -0
  36. package/dist/esm/internal/statements.js.map +1 -0
  37. package/dist/esm/package.json +4 -0
  38. package/dist/esm/projectionStore.js +147 -0
  39. package/dist/esm/projectionStore.js.map +1 -0
  40. package/package.json +33 -0
  41. package/src/config.ts +83 -0
  42. package/src/ensureCheckpointSchema.ts +51 -0
  43. package/src/index.ts +89 -0
  44. package/src/internal/ddl.ts +246 -0
  45. package/src/internal/statements.ts +144 -0
  46. 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
+ })