@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,147 @@
1
+ import { SqlClient } from '@effect/sql';
2
+ import { ORIGIN, Position } from '@kairos-es/core';
3
+ import { CheckpointSuperseded, ProjectionStoreError } from '@kairos-es/read';
4
+ import { Effect } from 'effect';
5
+ import { decodeReadPostgresConfig } from "./config.js";
6
+ import { resolveCheckpointNames } from "./internal/ddl.js";
7
+ import { advanceParams, compileStatements, keyParams } from "./internal/statements.js";
8
+ /**
9
+ * Turn a `ProjectionStoreError` out of a SQL fault, naming what was being
10
+ * attempted and for which key. `reason` is the human-readable line a supervisor
11
+ * logs, so it carries the key and the driver's message; `cause` carries the
12
+ * `SqlError` itself for anything that wants to inspect it.
13
+ */
14
+ const storeError = (attempt, key) => cause => new ProjectionStoreError({
15
+ reason: `${attempt} for projection '${key.projection}' partition '${key.partition}' failed: ${cause.message}`,
16
+ cause
17
+ });
18
+ /**
19
+ * Restore the port's declared error channel after the transaction: unwrap the
20
+ * caller's `E`, and classify a transaction-frame `SqlError` as infrastructure.
21
+ * Both `CheckpointSuperseded` and an already-classified `ProjectionStoreError`
22
+ * pass through as they are.
23
+ */
24
+ const unwrapCommitError = (key, error) => {
25
+ if (error._tag === 'ViewWriteFailed') {
26
+ return error.error;
27
+ }
28
+ if (error._tag === 'SqlError') {
29
+ return storeError('the checkpoint commit transaction', key)(error);
30
+ }
31
+ return error;
32
+ };
33
+ /**
34
+ * Build a durable `ProjectionStore` over the ambient `SqlClient`.
35
+ *
36
+ * A factory `Effect` rather than a `Layer`, matching the in-memory store: the view
37
+ * store is selected PER MATERIALISATION, so it is a value handed to a read model, not
38
+ * a context tag one store could claim process-wide. Per materialisation rather than
39
+ * per read model, because one read model may be materialised several times at once —
40
+ * a store from here beside an in-memory one, wired by `@kairos-es/read`'s
41
+ * `runProjections` — which is a shape a process-wide tag could not express at all. It
42
+ * acquires the client ONCE and closes over it, which is what keeps `commit`'s `R`
43
+ * down to the caller's own.
44
+ *
45
+ * **The atomic bracket covers writes issued through THIS client.** The caller's
46
+ * `viewWrites` join the transaction because `@effect/sql` carries the transaction's
47
+ * connection in the Effect CONTEXT, not in the client value — so any statement run
48
+ * through the same underlying client inside `commit` is in the same transaction,
49
+ * including through a differently-transformed copy of it. A write to a different
50
+ * database, or through an unrelated client, is NOT bracketed and the exactly-once
51
+ * property does not extend to it.
52
+ *
53
+ * `ensureCheckpointSchema(config)` must have run (in the caller's migrator or at
54
+ * boot) before the store is used; this factory performs no DDL, so it stays a pure
55
+ * runtime dependency on the client alone.
56
+ */
57
+ export const makeSqlProjectionStore = (config = {}) => Effect.gen(function* () {
58
+ // `withoutTransforms()` because this package takes the CONSUMER's generic
59
+ // client, which may carry a result-name transform (`@effect/sql` will happily
60
+ // camel-case every returned key). Under one, `last_position` would arrive as
61
+ // `lastPosition`, `readCheckpoint` would see `undefined` and report `ORIGIN`,
62
+ // and a durable projection would silently replay its whole backlog on every
63
+ // boot. Stripping the transform for OUR statements is the fix; the caller's
64
+ // own view writes keep their client's transforms and still join the same
65
+ // transaction (the connection travels in the context, not the client).
66
+ const sql = (yield* SqlClient.SqlClient).withoutTransforms();
67
+ // Decode INSIDE the Effect so a malformed config (empty/dotted atom, an
68
+ // over-63-byte derived name) surfaces as a defect on the fibre building the
69
+ // store rather than as an exception thrown out of the factory call, matching
70
+ // `ensureCheckpointSchema`.
71
+ const names = resolveCheckpointNames(decodeReadPostgresConfig(config));
72
+ const statements = compileStatements(names);
73
+ const readCheckpoint = key => sql.unsafe(statements.select, keyParams(key)).pipe(Effect.mapError(storeError('reading the checkpoint', key)), Effect.map(rows => {
74
+ const stored = rows[0]?.last_position;
75
+ // No row means never committed (or reset), which the port defines as
76
+ // `ORIGIN` — so a fresh read model and a reset one both simply catch up
77
+ // from the start of the log.
78
+ return stored === undefined ? ORIGIN :
79
+ // `Position.make` re-validates non-negativity, so a corrupted
80
+ // column becomes a loud defect here rather than a nonsensical
81
+ // cursor that silently reads nothing.
82
+ Position.make(BigInt(stored));
83
+ }));
84
+ /**
85
+ * The compare-and-set half of `commit`, run INSIDE the caller's transaction.
86
+ * Which shape is correct depends on `expected`; see `compileStatements` for
87
+ * why neither shape can serve both cases.
88
+ *
89
+ * Takes its three values as NAMED FIELDS, and `commit` hands it the very
90
+ * object the port handed `commit`, so `expected` and `next` are never lined up
91
+ * positionally anywhere between the port's boundary and the bound `$n`
92
+ * (`advanceParams` does the binding, also by name). They are both a `Position`,
93
+ * so nothing else would notice them swapping — and swapped, the guard would
94
+ * compare the stored cursor against the position it was about to write.
95
+ */
96
+ const guardedAdvance = args => {
97
+ const {
98
+ key,
99
+ expected
100
+ } = args;
101
+ const [text, attempt] = expected === ORIGIN ? [statements.upsertFromOrigin, 'inserting the first checkpoint'] : [statements.advance, 'advancing the checkpoint'];
102
+ // `next` is bound AS IS — it is the last processed event's position, never
103
+ // `expected + 1`: positions are strictly increasing but not gapless, so a
104
+ // jump is normal and arithmetic would be wrong.
105
+ return sql.unsafe(text, advanceParams(args)).pipe(Effect.mapError(storeError(attempt, key)), Effect.flatMap(rows =>
106
+ // Zero rows = the guard lost. Failing (rather than returning an
107
+ // outcome) is what aborts the enclosing transaction and rolls the
108
+ // view writes back WITH it.
109
+ rows.length === 0 ? Effect.fail(new CheckpointSuperseded({
110
+ key,
111
+ expected
112
+ })) : Effect.void));
113
+ };
114
+ const commit = args => sql.withTransaction(args.viewWrites.pipe(Effect.mapError(error => ({
115
+ _tag: 'ViewWriteFailed',
116
+ error
117
+ })),
118
+ // View writes first, guard last. Ordering is a performance choice,
119
+ // not a correctness one: the transaction makes the pair atomic
120
+ // either way, and deferring the advance holds the checkpoint row
121
+ // lock only for the advance rather than across the whole batch. (The
122
+ // in-memory store has no such freedom — with nothing to roll back it
123
+ // MUST check first.) No `uninterruptible` wrapper either:
124
+ // `withTransaction` masks interruption around its own frame, so an
125
+ // interrupt landing mid-body rolls back exactly like a failure.
126
+ // `args` itself, not three values pulled out of it in an order: see
127
+ // `guardedAdvance`.
128
+ Effect.zipRight(guardedAdvance(args)))).pipe(Effect.mapError(error => unwrapCommitError(args.key, error)));
129
+ const resetCheckpoint = key =>
130
+ // DELETE rather than `SET last_position = 0`, so "never committed" and
131
+ // "reset" stay ONE state — which is exactly what the port promises the
132
+ // read-back is, and what the in-memory store's map delete does.
133
+ sql.unsafe(statements.reset, keyParams(key)).pipe(Effect.mapError(storeError('resetting the checkpoint', key)), Effect.asVoid);
134
+ // Annotated at the definition site rather than only through the exported
135
+ // signature, per the rule on `ProjectionStore` itself.
136
+ const store = {
137
+ // Outlives the process, which is the R2 input: a durable view is legal only
138
+ // over a durable log, and the runner dies at construction if it is wired
139
+ // over an ephemeral one.
140
+ durability: 'durable',
141
+ readCheckpoint,
142
+ commit,
143
+ resetCheckpoint
144
+ };
145
+ return store;
146
+ });
147
+ //# sourceMappingURL=projectionStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectionStore.js","names":["SqlClient","ORIGIN","Position","CheckpointSuperseded","ProjectionStoreError","Effect","decodeReadPostgresConfig","resolveCheckpointNames","advanceParams","compileStatements","keyParams","storeError","attempt","key","cause","reason","projection","partition","message","unwrapCommitError","error","_tag","makeSqlProjectionStore","config","gen","sql","withoutTransforms","names","statements","readCheckpoint","unsafe","select","pipe","mapError","map","rows","stored","last_position","undefined","make","BigInt","guardedAdvance","args","expected","text","upsertFromOrigin","advance","flatMap","length","fail","void","commit","withTransaction","viewWrites","zipRight","resetCheckpoint","reset","asVoid","store","durability"],"sources":["../../src/projectionStore.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@kairos-es/read-postgres",
3
+ "version": "0.0.0",
4
+ "description": "Postgres ProjectionStore backend and checkpoint DDL 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/read-postgres"
10
+ },
11
+ "sideEffects": [],
12
+ "homepage": "https://github.com/neverbland/kairos-es",
13
+ "peerDependencies": {
14
+ "@effect/sql": "^0.52.0",
15
+ "effect": "^3.22.0",
16
+ "@kairos-es/read": "^0.0.0",
17
+ "@kairos-es/core": "^0.0.0"
18
+ },
19
+ "main": "./dist/cjs/index.js",
20
+ "module": "./dist/esm/index.js",
21
+ "types": "./dist/dts/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/dts/index.d.ts",
25
+ "import": "./dist/esm/index.js",
26
+ "default": "./dist/cjs/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "typesVersions": {
31
+ "*": {}
32
+ }
33
+ }
package/src/config.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Public configuration for the Postgres `ProjectionStore`.
3
+ *
4
+ * A read-side store is defined entirely by its `{ schema, tablePrefix }` naming
5
+ * pair: the checkpoint table name is DERIVED from it (see `internal/ddl.ts`), so
6
+ * one config value is one completely isolated checkpoint namespace, cohabiting
7
+ * per prefix exactly as the event store's objects do (ADR-0002).
8
+ *
9
+ * It is a `Schema`, not a bare interface, so malformed input fails LOUDLY at
10
+ * construction (`makeSqlProjectionStore`/`ensureCheckpointSchema` decode through
11
+ * it) rather than late and obscurely on the first commit — the same
12
+ * fail-at-the-boundary discipline core applies to `Tag`/`ReadLimit`. It rejects an
13
+ * empty or dotted `schema`/`tablePrefix` atom (a dot would break the
14
+ * qualified-name dot-split) and — via `oversizedIdentifier` — any pair whose
15
+ * derived table name would exceed Postgres's 63-byte identifier limit and be
16
+ * silently truncated.
17
+ *
18
+ * **WHY this config is DUPLICATED rather than imported from
19
+ * `@kairos-es/store-postgres`.** Importing it would mean peering that package,
20
+ * and peering an event-store BACKEND from the read side is precisely what the
21
+ * per-materialisation view-store design exists to avoid: a durable view must be
22
+ * free to sit over any log satisfying `DcbEventStore` — the in-memory oracle,
23
+ * Postgres, a future backend — so this package depends on the PORT
24
+ * (`@kairos-es/read`) and on `@effect/sql`, never on a sibling backend. The
25
+ * duplication also keeps the two guards honestly independent: each package
26
+ * measures only the identifiers IT materialises, so neither inherits a byte budget
27
+ * from names it never creates.
28
+ *
29
+ * The shape is intentionally the event store's MINUS everything read-side wiring
30
+ * has no business choosing: no `lockTimeout` (the checkpoint's guarded
31
+ * compare-and-set takes only ordinary row locks, and a blocked writer is a
32
+ * competing runner whose loss is a `CheckpointSuperseded` the supervisor already
33
+ * handles), no `pollInterval`/`catchUpPageSize` (those belong to `subscribe`, on
34
+ * the log side). What remains is naming, and only naming.
35
+ */
36
+ import { Schema } from 'effect'
37
+ import { oversizedIdentifier } from './internal/ddl'
38
+
39
+ /**
40
+ * A single Postgres identifier atom: non-empty and dot-free. Dots are barred
41
+ * because `schema`/`tablePrefix` are composed into a qualified name that is
42
+ * dot-split when quoted, so an embedded dot would silently re-partition the name
43
+ * into the wrong schema/object.
44
+ */
45
+ const IdentifierAtom = Schema.String.pipe(
46
+ Schema.nonEmptyString(),
47
+ Schema.pattern(/^[^.]+$/, {
48
+ identifier: 'PostgresIdentifierAtom',
49
+ description: 'a non-empty identifier atom containing no "."',
50
+ }),
51
+ )
52
+
53
+ /**
54
+ * Configuration for a read-side checkpoint store.
55
+ *
56
+ * - `schema` — the Postgres schema that owns the checkpoint table (default
57
+ * `public`). `ensureCheckpointSchema` creates it defensively.
58
+ * - `tablePrefix` — the base name the checkpoint table derives from (default
59
+ * `dcb_events`, giving `dcb_events_checkpoints`). Pass the SAME pair the event
60
+ * store is configured with and the checkpoint table lands beside that store's
61
+ * objects, one isolated store per prefix.
62
+ */
63
+ export const ReadPostgresConfig = Schema.Struct({
64
+ // `exact: true` keeps the decoded type at `key?: T` (never `T | undefined`),
65
+ // matching the repo's `exactOptionalPropertyTypes` discipline — an absent key,
66
+ // not an explicit `undefined`.
67
+ schema: Schema.optionalWith(IdentifierAtom, { exact: true }),
68
+ tablePrefix: Schema.optionalWith(IdentifierAtom, { exact: true }),
69
+ }).pipe(
70
+ // Cross-field guard: the derived table name must fit Postgres's 63-byte
71
+ // identifier limit. Returning the message string fails the decode with it;
72
+ // `undefined`/`true` passes.
73
+ Schema.filter((config) => oversizedIdentifier(config) ?? true),
74
+ )
75
+ export type ReadPostgresConfig = typeof ReadPostgresConfig.Type
76
+
77
+ /**
78
+ * Decode an unknown value into a validated `ReadPostgresConfig`, throwing a
79
+ * `ParseError` on any violation. Used by `makeSqlProjectionStore` and
80
+ * `ensureCheckpointSchema` so a bad config fails at construction time.
81
+ */
82
+ export const decodeReadPostgresConfig =
83
+ Schema.decodeUnknownSync(ReadPostgresConfig)
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `ensureCheckpointSchema` — the idempotent checkpoint DDL, as a building block
3
+ * rather than a migrator (ADR-0007, mirroring the event store's `ensureSchema`).
4
+ *
5
+ * It is a single `Effect<void, SqlError, SqlClient>` that creates (or refreshes)
6
+ * the one object this package owns: the checkpoint table. It requires the GENERIC
7
+ * `SqlClient` tag — not a package-owned client tag — precisely so a consumer can
8
+ * drop it straight into THEIR OWN migrator, e.g.:
9
+ *
10
+ * ```ts
11
+ * Migrator.fromRecord({
12
+ * '0002_kairos_checkpoints': ensureCheckpointSchema(config),
13
+ * })
14
+ * ```
15
+ *
16
+ * or simply run it once at boot, beside `ensureSchema` from the event-store
17
+ * backend. We ship no `FileSystem`/`Path`/`ChildProcess` dependency and no
18
+ * migrator: migration ORCHESTRATION belongs to the consumer, this package only
19
+ * provides the idempotent PAYLOAD. That is also why schema ownership never
20
+ * silently moves — a read model cannot conjure its own table at runtime.
21
+ *
22
+ * Idempotence: every statement is `CREATE … IF NOT EXISTS`, so re-running against
23
+ * an existing checkpoint table is a no-op. Names come pre-quoted from
24
+ * `internal/ddl.ts`; the DDL carries no bound values (pure schema).
25
+ *
26
+ * Unlike the runtime store, this path needs no `withoutTransforms()` guard: it
27
+ * reads no rows and passes no identifier through `sql(identifier)`, so a
28
+ * consumer's name transforms have nothing here to rewrite.
29
+ */
30
+ import { SqlClient, type SqlError } from '@effect/sql'
31
+ import { Effect } from 'effect'
32
+ import { decodeReadPostgresConfig, type ReadPostgresConfig } from './config'
33
+ import { resolveCheckpointNames, runCheckpointDdl } from './internal/ddl'
34
+
35
+ /**
36
+ * Produce the checkpoint migration payload for `config`. Resolves the table name
37
+ * once and runs the ordered idempotent DDL against whatever `SqlClient` the
38
+ * caller's migrator (or boot code) provides.
39
+ */
40
+ export const ensureCheckpointSchema = (
41
+ config: ReadPostgresConfig,
42
+ ): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
43
+ Effect.gen(function* () {
44
+ const sql = yield* SqlClient.SqlClient
45
+ // Decode through the config schema (the same guard `makeSqlProjectionStore`
46
+ // applies) so a malformed config fails at construction rather than
47
+ // mid-migration.
48
+ const names = resolveCheckpointNames(decodeReadPostgresConfig(config))
49
+ // Sequentially, in dependency order (schema → table).
50
+ yield* Effect.all(runCheckpointDdl(sql, names), { discard: true })
51
+ })
package/src/index.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @kairos-es/read-postgres — the SQL `ProjectionStore`: a durable checkpoint
3
+ * table plus the guarded compare-and-set that brackets a read model's own view
4
+ * writes in one ordinary transaction (ADR-0007).
5
+ *
6
+ * Peers `@effect/sql` and needs only the GENERIC `SqlClient`, deliberately not
7
+ * `@effect/sql-pg` and deliberately no reserved connection: the event store owns
8
+ * the LISTEN, so the read side needs no dedicated connection of its own, and
9
+ * requiring the generic client is what lets `ensureCheckpointSchema` drop into a
10
+ * consumer's existing migrator instead of shipping a migrator here.
11
+ *
12
+ * A store is one `{ schema, tablePrefix }` naming pair (`ReadPostgresConfig`) —
13
+ * the same pair the event store is configured with — so the checkpoint table
14
+ * cohabits PER PREFIX and ADR-0002's "each prefix is a complete, isolated store"
15
+ * keeps holding across the read side.
16
+ *
17
+ * Wiring, end to end:
18
+ *
19
+ * ```ts
20
+ * import {
21
+ * checkpointKey,
22
+ * DurableEventLog,
23
+ * forKey,
24
+ * ProjectionId,
25
+ * projectionLayer,
26
+ * } from '@kairos-es/read'
27
+ * import {
28
+ * ensureCheckpointSchema,
29
+ * makeSqlProjectionStore,
30
+ * } from '@kairos-es/read-postgres'
31
+ *
32
+ * const config = { schema: 'public', tablePrefix: 'dcb_events' }
33
+ *
34
+ * // 1. Register `ensureCheckpointSchema(config)` in YOUR migrator (or run it at
35
+ * // boot), beside the event-store backend's own `ensureSchema(config)`.
36
+ * // 2. Build the store where the read model is wired, so `commit` closes over the
37
+ * // acquired client and the read model itself stays free of `SqlClient`.
38
+ * const courseView = Layer.unwrapEffect(
39
+ * Effect.gen(function* () {
40
+ * const store = yield* makeSqlProjectionStore(config)
41
+ * return projectionLayer({
42
+ * // One store, many read models: `forKey` binds it to THIS one's checkpoint,
43
+ * // and from there no operation takes a key to get wrong.
44
+ * store: forKey(store, checkpointKey(ProjectionId.make('course-subs'))),
45
+ * slices,
46
+ * apply,
47
+ * })
48
+ * }),
49
+ * )
50
+ *
51
+ * // 3. Declare what the LOG is worth ONCE, beside the layer that chose it —
52
+ * // `DcbEventStore` exposes no durability, so the application is the only
53
+ * // honest source, and R2 (a durable view needs a durable log) is checked
54
+ * // against this. It is a required input of every projection layer, and one
55
+ * // declaration satisfies every one of them.
56
+ * const main = Layer.provide(
57
+ * Layer.mergeAll(courseView, studentView),
58
+ * Layer.mergeAll(eventStoreLayer, DurableEventLog, SerializerDefault),
59
+ * )
60
+ * ```
61
+ *
62
+ * The resulting layer requires the `SqlClient` the checkpoint (and typically the
63
+ * view) is written through, the `EventLogDurability` declaration above, plus
64
+ * whatever the read model's own `apply` needs. The checkpoint table is assumed to
65
+ * already exist, exactly as the event store's `layer` assumes its objects do: this
66
+ * package performs no DDL at runtime.
67
+ *
68
+ * `commit` is `sql.withTransaction(viewWrites *> guardedAdvance)`. The author's own
69
+ * `sql` statements join that transaction through the same client, and
70
+ * `withTransaction` is savepoint-aware, so a projection already inside a
71
+ * transaction composes without a second physical one. A guarded advance touching
72
+ * zero rows raises `CheckpointSuperseded`, whose FAILURE aborts the transaction and
73
+ * rolls the view writes back with it — the abort that makes the projection
74
+ * exactly-once. An infrastructure fault surfaces as `ProjectionStoreError` on the
75
+ * ERROR CHANNEL rather than as a defect, inverting the event store's rule
76
+ * (ADR-0002) because the read side has a supervisor above it that must distinguish
77
+ * retry-with-backoff from give-up.
78
+ *
79
+ * The config and the identifier guards are duplicated here rather than imported
80
+ * from `@kairos-es/store-postgres`, and that duplication is deliberate: peering
81
+ * the event-store backend would couple the read side to one specific event-store
82
+ * implementation, which is precisely what per-materialisation view-store selection
83
+ * exists to avoid. A durable view must be free to sit over any log satisfying
84
+ * `DcbEventStore`, so this package depends on the port (`@kairos-es/read`) and
85
+ * on `@effect/sql`, never on a sibling backend.
86
+ */
87
+ export type { ReadPostgresConfig } from './config'
88
+ export { ensureCheckpointSchema } from './ensureCheckpointSchema'
89
+ export { makeSqlProjectionStore } from './projectionStore'
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Internal object-naming and DDL for the Postgres `ProjectionStore`.
3
+ *
4
+ * This module is the single source of truth for the ONE object this package owns
5
+ * — the checkpoint table — and for the idempotent DDL that creates it. It is
6
+ * factored apart from the public surface for the same reason the event store's
7
+ * `internal/ddl.ts` is: the name is derived ONCE, from `{ schema, tablePrefix }`,
8
+ * and reused by both `ensureCheckpointSchema` (the migration payload) and the
9
+ * runtime statements in `projectionStore.ts`, so a migration and a query can
10
+ * never disagree about what the table is called.
11
+ *
12
+ * The checkpoint table derives from the SAME `{ schema, tablePrefix }` pair the
13
+ * event store uses, so a checkpoint table cohabits PER PREFIX: two prefixes are
14
+ * two complete, isolated stores (ADR-0002) with two independent checkpoint
15
+ * namespaces, and that isolation keeps holding once the read side is added rather
16
+ * than being punctured by one shared checkpoint table.
17
+ *
18
+ * WHY names are never string-concatenated into a query: every identifier is
19
+ * pre-quoted here (`quoteQualified` for the dotted table, `quoteAtom` for a bare
20
+ * atom) and embedded as literal text into a `sql.unsafe(text, params)` statement,
21
+ * exactly as the event store's read plans are; VALUES are always bound `$n`
22
+ * parameters and never interpolated. A hostile or merely awkward
23
+ * `schema`/`tablePrefix` therefore cannot inject SQL or collide with a reserved
24
+ * word.
25
+ *
26
+ * The runtime deliberately does NOT route these names through `sql(identifier)`.
27
+ * This package takes the CONSUMER's generic `SqlClient`, which may carry a
28
+ * query-name transform (`@effect/sql` applies it to every `sql(identifier)`), so
29
+ * a camel-casing consumer would silently rewrite `last_position` to
30
+ * `lastPosition` and address a column that does not exist. Pre-quoted text inside
31
+ * `sql.unsafe` is transform-proof; the result side is handled by
32
+ * `withoutTransforms()` in `projectionStore.ts`.
33
+ */
34
+ import type { SqlClient, Statement } from '@effect/sql'
35
+
36
+ /**
37
+ * The one object a read-side config owns. An interface rather than a bare string
38
+ * so the `{ schema, tablePrefix }` atoms stay available to error messages and so
39
+ * a second derived object (should one ever be needed) is an additive change here
40
+ * rather than a new parameter at every call site.
41
+ */
42
+ export interface ResolvedCheckpointNames {
43
+ readonly schema: string
44
+ readonly tablePrefix: string
45
+ /** `<schema>.<prefix>_checkpoints` — the scalar checkpoint table. */
46
+ readonly checkpointTable: string
47
+ }
48
+
49
+ /**
50
+ * Default schema when the config omits one. Identical to the event store's
51
+ * default, on purpose: the common wiring is one `{ schema, tablePrefix }` pair
52
+ * used for both sides, so both defaults must agree or a caller omitting the
53
+ * config would silently split the log and its checkpoints across two schemas.
54
+ */
55
+ export const DEFAULT_SCHEMA = 'public'
56
+ /** Default table prefix when the config omits one — again the event store's. */
57
+ export const DEFAULT_TABLE_PREFIX = 'dcb_events'
58
+
59
+ /** The suffix appended to the prefix to name the checkpoint table. */
60
+ const CHECKPOINT_SUFFIX = '_checkpoints'
61
+
62
+ /**
63
+ * Postgres truncates any identifier to 63 bytes (`NAMEDATALEN - 1`) with only a
64
+ * notice. Two configs whose derived checkpoint-table names share a 63-byte
65
+ * prefix would truncate onto ONE table, and `CREATE TABLE IF NOT EXISTS` would
66
+ * then see a name-match and SKIP creating the second: two supposedly isolated
67
+ * stores would silently share one checkpoint namespace, so a rebuild
68
+ * (`resetCheckpoint`) of one read model could delete the other's cursor. Loud
69
+ * rejection at the boundary beats that.
70
+ */
71
+ const MAX_IDENTIFIER_BYTES = 63
72
+
73
+ /** UTF-8 byte length (identifiers are bytes, not chars, to Postgres). */
74
+ const utf8ByteLength = (value: string): number =>
75
+ new TextEncoder().encode(value).length
76
+
77
+ /**
78
+ * The single-atom identifiers Postgres actually materialises for a config. A
79
+ * `schema.table` qualified name is TWO atoms to Postgres, so each is measured
80
+ * separately rather than the dotted whole.
81
+ *
82
+ * The primary key's implicit index (`<table>_pkey`) is deliberately not measured:
83
+ * Postgres derives and de-duplicates that name itself (appending a digit when it
84
+ * is taken), so it cannot collide silently the way an explicitly-named
85
+ * `CREATE INDEX IF NOT EXISTS` can.
86
+ */
87
+ const derivedAtoms = (config: {
88
+ readonly schema?: string
89
+ readonly tablePrefix?: string
90
+ }): ReadonlyArray<readonly [string, string]> => {
91
+ const schema = config.schema ?? DEFAULT_SCHEMA
92
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX
93
+ return [
94
+ ['schema', schema],
95
+ ['checkpoint table', `${tablePrefix}${CHECKPOINT_SUFFIX}`],
96
+ ]
97
+ }
98
+
99
+ /**
100
+ * The first derived identifier that exceeds 63 bytes, as a human-readable
101
+ * message, or `undefined` when every name fits. Shared by
102
+ * `resolveCheckpointNames` (which throws) and the `ReadPostgresConfig` schema
103
+ * filter (which fails validation), so the byte bound is enforced identically at
104
+ * both the name-derivation and the config-decode boundary.
105
+ *
106
+ * The bound is checked INDEPENDENTLY of the event store's: this package measures
107
+ * only the names IT materialises, so a prefix accepted there may be rejected here
108
+ * (and vice versa). Each package guards its own names, which is what lets the
109
+ * read side stay uncoupled from any particular event-store backend.
110
+ */
111
+ export const oversizedIdentifier = (config: {
112
+ readonly schema?: string
113
+ readonly tablePrefix?: string
114
+ }): string | undefined => {
115
+ for (const [label, atom] of derivedAtoms(config)) {
116
+ const bytes = utf8ByteLength(atom)
117
+ if (bytes > MAX_IDENTIFIER_BYTES) {
118
+ return `the ${label} identifier "${atom}" is ${bytes} bytes, over Postgres's ${MAX_IDENTIFIER_BYTES}-byte limit; it would be silently truncated, letting two prefixes share one checkpoint table`
119
+ }
120
+ }
121
+ return undefined
122
+ }
123
+
124
+ /**
125
+ * Derive the checkpoint table name from `{ schema, tablePrefix }`. The table
126
+ * carries its schema explicitly so `quoteQualified` dot-splits it into the two
127
+ * atoms Postgres expects.
128
+ *
129
+ * Throws at construction if a derived identifier would be silently truncated (see
130
+ * `oversizedIdentifier`) — a loud failure at the single choke point beats an
131
+ * invisible cross-prefix checkpoint collision later.
132
+ */
133
+ export const resolveCheckpointNames = (config: {
134
+ readonly schema?: string
135
+ readonly tablePrefix?: string
136
+ }): ResolvedCheckpointNames => {
137
+ const problem = oversizedIdentifier(config)
138
+ if (problem !== undefined) {
139
+ throw new Error(`resolveCheckpointNames: ${problem}`)
140
+ }
141
+ const schema = config.schema ?? DEFAULT_SCHEMA
142
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX
143
+ return {
144
+ schema,
145
+ tablePrefix,
146
+ checkpointTable: `${schema}.${tablePrefix}${CHECKPOINT_SUFFIX}`,
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Quote a possibly-qualified identifier for embedding as literal SQL text.
152
+ * Splits on `.` and double-quotes each atom, mirroring what `sql(identifier)`
153
+ * emits, so a pre-quoted name and an `sql(identifier)` reference resolve to
154
+ * exactly the same object. Any embedded `"` is doubled per SQL
155
+ * identifier-quoting rules. (The dot-split is why `ReadPostgresConfig` bars a dot
156
+ * inside either atom: an embedded dot would silently re-partition the name into
157
+ * the wrong schema.)
158
+ */
159
+ export const quoteQualified = (name: string): string =>
160
+ name
161
+ .split('.')
162
+ .map((atom) => `"${atom.replace(/"/g, '""')}"`)
163
+ .join('.')
164
+
165
+ /**
166
+ * A quoted single-atom identifier (no dot-splitting) — used where a bare name is
167
+ * required, e.g. the schema in `CREATE SCHEMA` and the column names below.
168
+ */
169
+ const quoteAtom = (name: string): string => `"${name.replace(/"/g, '""')}"`
170
+
171
+ /**
172
+ * The checkpoint table's columns, PRE-QUOTED, as one shared descriptor.
173
+ *
174
+ * Shared by the DDL here and by every runtime statement in `projectionStore.ts`,
175
+ * so a column rename cannot half-land: the table it creates and the statements
176
+ * that address it read the same constant. Pre-quoted because `partition` is
177
+ * Postgres's own partitioning vocabulary — it parses unquoted today, but quoting
178
+ * removes the question, and quoting all three keeps the emitted SQL uniform.
179
+ *
180
+ * These names are FIXED and owned by this package (never config-derived), which
181
+ * is why they are plain constants rather than something the caller can influence.
182
+ */
183
+ export const CHECKPOINT_COLUMNS = {
184
+ /** The read model's `ProjectionId`. */
185
+ projection: quoteAtom('projection'),
186
+ /** The read model's `PartitionId` (`default` while sharding is cut). */
187
+ partition: quoteAtom('partition'),
188
+ /**
189
+ * The scalar checkpoint: `bigint`, because a `Position` is 64-bit and exceeds
190
+ * JS `number` safety at 2^53. It is read back as a decimal STRING and
191
+ * reconstructed with `BigInt(...)`, never coerced through `number`.
192
+ */
193
+ lastPosition: quoteAtom('last_position'),
194
+ } as const
195
+
196
+ /**
197
+ * The ordered, idempotent DDL that creates (or refreshes) the checkpoint table.
198
+ * Each entry is a `sql.unsafe` statement string; no VALUES are involved (pure
199
+ * DDL), and every identifier is pre-quoted to match the runtime's resolution.
200
+ *
201
+ * Notes on the shape, both load-bearing:
202
+ *
203
+ * - `PRIMARY KEY (projection, partition)` is what makes `readCheckpoint` an index
204
+ * lookup AND supplies the arbiter index the first-commit upsert's
205
+ * `ON CONFLICT (projection, partition)` names. Without it that upsert would not
206
+ * compile, so the key is not merely hygiene.
207
+ * - `last_position bigint NOT NULL` with NO row for a never-committed key: a key
208
+ * reads back as `ORIGIN` by ABSENCE, so "never committed" and "reset" are one
209
+ * state (`resetCheckpoint` deletes the row rather than zeroing it), exactly as
210
+ * the in-memory store's map deletes its entry.
211
+ * - No autovacuum storage parameters, unlike the event store's tables. Those are
212
+ * tuned for an append-heavy, effectively-immutable log; this table is a handful
213
+ * of hot-UPDATEd rows, where the stock thresholds already fire aggressively.
214
+ */
215
+ export const checkpointDdlStatements = (
216
+ names: ResolvedCheckpointNames,
217
+ ): ReadonlyArray<string> => {
218
+ const table = quoteQualified(names.checkpointTable)
219
+ const schema = quoteAtom(names.schema)
220
+ const columns = CHECKPOINT_COLUMNS
221
+
222
+ return [
223
+ // The schema may already exist (e.g. `public`); create it defensively so a
224
+ // custom schema does not require a separate provisioning step.
225
+ `CREATE SCHEMA IF NOT EXISTS ${schema};`,
226
+
227
+ `CREATE TABLE IF NOT EXISTS ${table} (
228
+ ${columns.projection} text NOT NULL,
229
+ ${columns.partition} text NOT NULL,
230
+ ${columns.lastPosition} bigint NOT NULL,
231
+ PRIMARY KEY (${columns.projection}, ${columns.partition})
232
+ );`,
233
+ ]
234
+ }
235
+
236
+ /**
237
+ * Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
238
+ * public `ensureCheckpointSchema`) so the same builder feeds both the public
239
+ * migration payload and any internal test setup. Each statement is a separate
240
+ * `sql.unsafe` call so a driver that rejects multi-statement strings still works.
241
+ */
242
+ export const runCheckpointDdl = (
243
+ sql: SqlClient.SqlClient,
244
+ names: ResolvedCheckpointNames,
245
+ ): ReadonlyArray<Statement.Statement<unknown>> =>
246
+ checkpointDdlStatements(names).map((statement) => sql.unsafe(statement))