@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,100 @@
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 { type ResolvedCheckpointNames } from './ddl.js';
23
+ /**
24
+ * The four statement texts a store issues, compiled ONCE per store from the
25
+ * resolved names — the same compile-once discipline as the event store's read
26
+ * plans, so a long-lived projection re-binds only its parameters rather than
27
+ * re-assembling SQL per batch.
28
+ *
29
+ * All four take their parameters in ONE positional order — `$1` projection,
30
+ * `$2` partition, `$3` next, `$4` expected — and no consumer writes that order
31
+ * out: `keyParams` binds the two the `select` and the `reset` take, and
32
+ * `advanceParams` binds all four for the two guard shapes, reading each from a
33
+ * NAMED field. That naming is the point rather than a nicety, because the type
34
+ * system cannot help here: `next` and `expected` are the same type, so a
35
+ * positional list makes `[..., expected, next]` typecheck, and a transposed guard
36
+ * would compare the stored cursor against the position it was about to write —
37
+ * inverting the compare-and-set that makes a projection exactly-once. Reading the
38
+ * fields by name does not make the mistake impossible, but it makes it
39
+ * unwritable without naming the wrong field, which review and a diff can see.
40
+ */
41
+ export interface CheckpointStatements {
42
+ readonly select: string;
43
+ readonly upsertFromOrigin: string;
44
+ readonly advance: string;
45
+ readonly reset: string;
46
+ }
47
+ /**
48
+ * Build the statement texts for a resolved name set.
49
+ *
50
+ * **The two guard shapes, and why one cannot serve both.** The guarded advance is
51
+ * a compare-and-set on `last_position`, and its correct SQL depends on whether the
52
+ * expected value is `ORIGIN`:
53
+ *
54
+ * - `expected === ORIGIN` needs `upsertFromOrigin`. A never-committed key has NO
55
+ * ROW (a key reads back as `ORIGIN` by absence, so "fresh" and "reset" are one
56
+ * state), so a plain `UPDATE` would match zero rows and report
57
+ * `CheckpointSuperseded` — a fresh read model could never make its FIRST commit.
58
+ * The `ON CONFLICT … DO UPDATE … WHERE chk.last_position = $4` arm keeps the
59
+ * upsert a real guard rather than a blind write: two racers both starting from
60
+ * `ORIGIN` cannot both win, because the second one's arm blocks on the first's
61
+ * uncommitted row, then re-reads it and finds a position that is no longer
62
+ * `ORIGIN`, matching zero rows. That arm also makes "row present at `0`"
63
+ * (an operator zeroing the column by hand instead of deleting the row) behave
64
+ * identically to "row absent", so the two ways of expressing a reset are
65
+ * indistinguishable to a first commit.
66
+ * - `expected > ORIGIN` needs `advance`, the plain `UPDATE`. The upsert would be
67
+ * WRONG here: `ON CONFLICT` guards only the branch where the row exists, so an
68
+ * absent row would be INSERTED unconditionally — a `resetCheckpoint` racing an
69
+ * in-flight commit would be silently undone, the key resurrected at `next`, and
70
+ * the entire backlog the reset existed to replay skipped with no error at all.
71
+ * The `UPDATE`'s zero rows for an absent row is the CORRECT outcome: somebody
72
+ * reset it, so this commit must lose and the runner must re-read.
73
+ *
74
+ * Both shapes `RETURNING` a column so the outcome is a ROW COUNT: zero rows means
75
+ * the guard lost. That mirrors the event store's append, where a conflict is the
76
+ * function's zero-row result and never a caught SQL error — no unique-violation
77
+ * or serialisation-failure path to classify, so a lost guard can never arrive
78
+ * mistagged as an infrastructure fault.
79
+ *
80
+ * The `AS chk` alias exists so the `DO UPDATE` guard can reference the EXISTING
81
+ * row unambiguously: the insert target is schema-qualified, and without an alias
82
+ * the guard would need a three-part column reference.
83
+ */
84
+ export declare const compileStatements: (names: ResolvedCheckpointNames) => CheckpointStatements;
85
+ /** The `$1`/`$2` key parameters the `select` and the `reset` take. */
86
+ export declare const keyParams: (key: CheckpointKey) => ReadonlyArray<unknown>;
87
+ /**
88
+ * The `$1`-`$4` parameters both guard shapes take, read from named fields.
89
+ *
90
+ * The argument is an object rather than three positional parameters for the reason
91
+ * `CheckpointStatements` gives: `next` and `expected` are interchangeable to the
92
+ * type checker and catastrophic to swap, so the ONE place their order is written
93
+ * out reads each of them by name.
94
+ */
95
+ export declare const advanceParams: (args: {
96
+ readonly key: CheckpointKey;
97
+ readonly expected: Position;
98
+ readonly next: Position;
99
+ }) => ReadonlyArray<unknown>;
100
+ //# sourceMappingURL=statements.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"statements.d.ts","sourceRoot":"","sources":["../../../src/internal/statements.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAA;AACpD,OAAO,EAGL,KAAK,uBAAuB,EAC7B,MAAM,OAAO,CAAA;AAEd;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,uBAAuB,KAC7B,oBA2BF,CAAA;AAED,sEAAsE;AACtE,eAAO,MAAM,SAAS,GAAI,KAAK,aAAa,KAAG,aAAa,CAAC,OAAO,CAGnE,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM;IAClC,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAA;IAC3B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC3B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CACxB,KAAG,aAAa,CAAC,OAAO,CAKxB,CAAA"}
@@ -0,0 +1,30 @@
1
+ import { SqlClient } from '@effect/sql';
2
+ import { type ProjectionStore } from '@kairos-es/read';
3
+ import { Effect } from 'effect';
4
+ import { type ReadPostgresConfig } from './config.js';
5
+ /**
6
+ * Build a durable `ProjectionStore` over the ambient `SqlClient`.
7
+ *
8
+ * A factory `Effect` rather than a `Layer`, matching the in-memory store: the view
9
+ * store is selected PER MATERIALISATION, so it is a value handed to a read model, not
10
+ * a context tag one store could claim process-wide. Per materialisation rather than
11
+ * per read model, because one read model may be materialised several times at once —
12
+ * a store from here beside an in-memory one, wired by `@kairos-es/read`'s
13
+ * `runProjections` — which is a shape a process-wide tag could not express at all. It
14
+ * acquires the client ONCE and closes over it, which is what keeps `commit`'s `R`
15
+ * down to the caller's own.
16
+ *
17
+ * **The atomic bracket covers writes issued through THIS client.** The caller's
18
+ * `viewWrites` join the transaction because `@effect/sql` carries the transaction's
19
+ * connection in the Effect CONTEXT, not in the client value — so any statement run
20
+ * through the same underlying client inside `commit` is in the same transaction,
21
+ * including through a differently-transformed copy of it. A write to a different
22
+ * database, or through an unrelated client, is NOT bracketed and the exactly-once
23
+ * property does not extend to it.
24
+ *
25
+ * `ensureCheckpointSchema(config)` must have run (in the caller's migrator or at
26
+ * boot) before the store is used; this factory performs no DDL, so it stays a pure
27
+ * runtime dependency on the client alone.
28
+ */
29
+ export declare const makeSqlProjectionStore: (config?: ReadPostgresConfig) => Effect.Effect<ProjectionStore, never, SqlClient.SqlClient>;
30
+ //# sourceMappingURL=projectionStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"projectionStore.d.ts","sourceRoot":"","sources":["../../src/projectionStore.ts"],"names":[],"mappings":"AA6CA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAEvC,OAAO,EAGL,KAAK,eAAe,EAErB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAA4B,KAAK,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAiF5E;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,sBAAsB,GACjC,SAAQ,kBAAuB,KAC9B,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,KAAK,EAAE,SAAS,CAAC,SAAS,CA0IxD,CAAA"}
@@ -0,0 +1,79 @@
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.js";
38
+ /**
39
+ * A single Postgres identifier atom: non-empty and dot-free. Dots are barred
40
+ * because `schema`/`tablePrefix` are composed into a qualified name that is
41
+ * dot-split when quoted, so an embedded dot would silently re-partition the name
42
+ * into the wrong schema/object.
43
+ */
44
+ const IdentifierAtom = /*#__PURE__*/Schema.String.pipe(/*#__PURE__*/Schema.nonEmptyString(), /*#__PURE__*/Schema.pattern(/^[^.]+$/, {
45
+ identifier: 'PostgresIdentifierAtom',
46
+ description: 'a non-empty identifier atom containing no "."'
47
+ }));
48
+ /**
49
+ * Configuration for a read-side checkpoint store.
50
+ *
51
+ * - `schema` — the Postgres schema that owns the checkpoint table (default
52
+ * `public`). `ensureCheckpointSchema` creates it defensively.
53
+ * - `tablePrefix` — the base name the checkpoint table derives from (default
54
+ * `dcb_events`, giving `dcb_events_checkpoints`). Pass the SAME pair the event
55
+ * store is configured with and the checkpoint table lands beside that store's
56
+ * objects, one isolated store per prefix.
57
+ */
58
+ export const ReadPostgresConfig = /*#__PURE__*/Schema.Struct({
59
+ // `exact: true` keeps the decoded type at `key?: T` (never `T | undefined`),
60
+ // matching the repo's `exactOptionalPropertyTypes` discipline — an absent key,
61
+ // not an explicit `undefined`.
62
+ schema: Schema.optionalWith(IdentifierAtom, {
63
+ exact: true
64
+ }),
65
+ tablePrefix: Schema.optionalWith(IdentifierAtom, {
66
+ exact: true
67
+ })
68
+ }).pipe(/*#__PURE__*/
69
+ // Cross-field guard: the derived table name must fit Postgres's 63-byte
70
+ // identifier limit. Returning the message string fails the decode with it;
71
+ // `undefined`/`true` passes.
72
+ Schema.filter(config => oversizedIdentifier(config) ?? true));
73
+ /**
74
+ * Decode an unknown value into a validated `ReadPostgresConfig`, throwing a
75
+ * `ParseError` on any violation. Used by `makeSqlProjectionStore` and
76
+ * `ensureCheckpointSchema` so a bad config fails at construction time.
77
+ */
78
+ export const decodeReadPostgresConfig = /*#__PURE__*/Schema.decodeUnknownSync(ReadPostgresConfig);
79
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","names":["Schema","oversizedIdentifier","IdentifierAtom","String","pipe","nonEmptyString","pattern","identifier","description","ReadPostgresConfig","Struct","schema","optionalWith","exact","tablePrefix","filter","config","decodeReadPostgresConfig","decodeUnknownSync"],"sources":["../../src/config.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,SAASA,MAAM,QAAQ,QAAQ;AAC/B,SAASC,mBAAmB,QAAQ,mBAAgB;AAEpD;;;;;;AAMA,MAAMC,cAAc,gBAAGF,MAAM,CAACG,MAAM,CAACC,IAAI,cACvCJ,MAAM,CAACK,cAAc,EAAE,eACvBL,MAAM,CAACM,OAAO,CAAC,SAAS,EAAE;EACxBC,UAAU,EAAE,wBAAwB;EACpCC,WAAW,EAAE;CACd,CAAC,CACH;AAED;;;;;;;;;;AAUA,OAAO,MAAMC,kBAAkB,gBAAGT,MAAM,CAACU,MAAM,CAAC;EAC9C;EACA;EACA;EACAC,MAAM,EAAEX,MAAM,CAACY,YAAY,CAACV,cAAc,EAAE;IAAEW,KAAK,EAAE;EAAI,CAAE,CAAC;EAC5DC,WAAW,EAAEd,MAAM,CAACY,YAAY,CAACV,cAAc,EAAE;IAAEW,KAAK,EAAE;EAAI,CAAE;CACjE,CAAC,CAACT,IAAI;AACL;AACA;AACA;AACAJ,MAAM,CAACe,MAAM,CAAEC,MAAM,IAAKf,mBAAmB,CAACe,MAAM,CAAC,IAAI,IAAI,CAAC,CAC/D;AAGD;;;;;AAKA,OAAO,MAAMC,wBAAwB,gBACnCjB,MAAM,CAACkB,iBAAiB,CAACT,kBAAkB,CAAC","ignoreList":[]}
@@ -0,0 +1,50 @@
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 } from '@effect/sql';
31
+ import { Effect } from 'effect';
32
+ import { decodeReadPostgresConfig } from "./config.js";
33
+ import { resolveCheckpointNames, runCheckpointDdl } from "./internal/ddl.js";
34
+ /**
35
+ * Produce the checkpoint migration payload for `config`. Resolves the table name
36
+ * once and runs the ordered idempotent DDL against whatever `SqlClient` the
37
+ * caller's migrator (or boot code) provides.
38
+ */
39
+ export const ensureCheckpointSchema = config => Effect.gen(function* () {
40
+ const sql = yield* SqlClient.SqlClient;
41
+ // Decode through the config schema (the same guard `makeSqlProjectionStore`
42
+ // applies) so a malformed config fails at construction rather than
43
+ // mid-migration.
44
+ const names = resolveCheckpointNames(decodeReadPostgresConfig(config));
45
+ // Sequentially, in dependency order (schema → table).
46
+ yield* Effect.all(runCheckpointDdl(sql, names), {
47
+ discard: true
48
+ });
49
+ });
50
+ //# sourceMappingURL=ensureCheckpointSchema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ensureCheckpointSchema.js","names":["SqlClient","Effect","decodeReadPostgresConfig","resolveCheckpointNames","runCheckpointDdl","ensureCheckpointSchema","config","gen","sql","names","all","discard"],"sources":["../../src/ensureCheckpointSchema.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAASA,SAAS,QAAuB,aAAa;AACtD,SAASC,MAAM,QAAQ,QAAQ;AAC/B,SAASC,wBAAwB,QAAiC,aAAU;AAC5E,SAASC,sBAAsB,EAAEC,gBAAgB,QAAQ,mBAAgB;AAEzE;;;;;AAKA,OAAO,MAAMC,sBAAsB,GACjCC,MAA0B,IAE1BL,MAAM,CAACM,GAAG,CAAC,aAAS;EAClB,MAAMC,GAAG,GAAG,OAAOR,SAAS,CAACA,SAAS;EACtC;EACA;EACA;EACA,MAAMS,KAAK,GAAGN,sBAAsB,CAACD,wBAAwB,CAACI,MAAM,CAAC,CAAC;EACtE;EACA,OAAOL,MAAM,CAACS,GAAG,CAACN,gBAAgB,CAACI,GAAG,EAAEC,KAAK,CAAC,EAAE;IAAEE,OAAO,EAAE;EAAI,CAAE,CAAC;AACpE,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1,3 @@
1
+ export { ensureCheckpointSchema } from "./ensureCheckpointSchema.js";
2
+ export { makeSqlProjectionStore } from "./projectionStore.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["ensureCheckpointSchema","makeSqlProjectionStore"],"sources":["../../src/index.ts"],"sourcesContent":[null],"mappings":"AAuFA,SAASA,sBAAsB,QAAQ,6BAA0B;AACjE,SAASC,sBAAsB,QAAQ,sBAAmB","ignoreList":[]}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Default schema when the config omits one. Identical to the event store's
3
+ * default, on purpose: the common wiring is one `{ schema, tablePrefix }` pair
4
+ * used for both sides, so both defaults must agree or a caller omitting the
5
+ * config would silently split the log and its checkpoints across two schemas.
6
+ */
7
+ export const DEFAULT_SCHEMA = 'public';
8
+ /** Default table prefix when the config omits one — again the event store's. */
9
+ export const DEFAULT_TABLE_PREFIX = 'dcb_events';
10
+ /** The suffix appended to the prefix to name the checkpoint table. */
11
+ const CHECKPOINT_SUFFIX = '_checkpoints';
12
+ /**
13
+ * Postgres truncates any identifier to 63 bytes (`NAMEDATALEN - 1`) with only a
14
+ * notice. Two configs whose derived checkpoint-table names share a 63-byte
15
+ * prefix would truncate onto ONE table, and `CREATE TABLE IF NOT EXISTS` would
16
+ * then see a name-match and SKIP creating the second: two supposedly isolated
17
+ * stores would silently share one checkpoint namespace, so a rebuild
18
+ * (`resetCheckpoint`) of one read model could delete the other's cursor. Loud
19
+ * rejection at the boundary beats that.
20
+ */
21
+ const MAX_IDENTIFIER_BYTES = 63;
22
+ /** UTF-8 byte length (identifiers are bytes, not chars, to Postgres). */
23
+ const utf8ByteLength = value => new TextEncoder().encode(value).length;
24
+ /**
25
+ * The single-atom identifiers Postgres actually materialises for a config. A
26
+ * `schema.table` qualified name is TWO atoms to Postgres, so each is measured
27
+ * separately rather than the dotted whole.
28
+ *
29
+ * The primary key's implicit index (`<table>_pkey`) is deliberately not measured:
30
+ * Postgres derives and de-duplicates that name itself (appending a digit when it
31
+ * is taken), so it cannot collide silently the way an explicitly-named
32
+ * `CREATE INDEX IF NOT EXISTS` can.
33
+ */
34
+ const derivedAtoms = config => {
35
+ const schema = config.schema ?? DEFAULT_SCHEMA;
36
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
37
+ return [['schema', schema], ['checkpoint table', `${tablePrefix}${CHECKPOINT_SUFFIX}`]];
38
+ };
39
+ /**
40
+ * The first derived identifier that exceeds 63 bytes, as a human-readable
41
+ * message, or `undefined` when every name fits. Shared by
42
+ * `resolveCheckpointNames` (which throws) and the `ReadPostgresConfig` schema
43
+ * filter (which fails validation), so the byte bound is enforced identically at
44
+ * both the name-derivation and the config-decode boundary.
45
+ *
46
+ * The bound is checked INDEPENDENTLY of the event store's: this package measures
47
+ * only the names IT materialises, so a prefix accepted there may be rejected here
48
+ * (and vice versa). Each package guards its own names, which is what lets the
49
+ * read side stay uncoupled from any particular event-store backend.
50
+ */
51
+ export const oversizedIdentifier = config => {
52
+ for (const [label, atom] of derivedAtoms(config)) {
53
+ const bytes = utf8ByteLength(atom);
54
+ if (bytes > MAX_IDENTIFIER_BYTES) {
55
+ 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`;
56
+ }
57
+ }
58
+ return undefined;
59
+ };
60
+ /**
61
+ * Derive the checkpoint table name from `{ schema, tablePrefix }`. The table
62
+ * carries its schema explicitly so `quoteQualified` dot-splits it into the two
63
+ * atoms Postgres expects.
64
+ *
65
+ * Throws at construction if a derived identifier would be silently truncated (see
66
+ * `oversizedIdentifier`) — a loud failure at the single choke point beats an
67
+ * invisible cross-prefix checkpoint collision later.
68
+ */
69
+ export const resolveCheckpointNames = config => {
70
+ const problem = oversizedIdentifier(config);
71
+ if (problem !== undefined) {
72
+ throw new Error(`resolveCheckpointNames: ${problem}`);
73
+ }
74
+ const schema = config.schema ?? DEFAULT_SCHEMA;
75
+ const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
76
+ return {
77
+ schema,
78
+ tablePrefix,
79
+ checkpointTable: `${schema}.${tablePrefix}${CHECKPOINT_SUFFIX}`
80
+ };
81
+ };
82
+ /**
83
+ * Quote a possibly-qualified identifier for embedding as literal SQL text.
84
+ * Splits on `.` and double-quotes each atom, mirroring what `sql(identifier)`
85
+ * emits, so a pre-quoted name and an `sql(identifier)` reference resolve to
86
+ * exactly the same object. Any embedded `"` is doubled per SQL
87
+ * identifier-quoting rules. (The dot-split is why `ReadPostgresConfig` bars a dot
88
+ * inside either atom: an embedded dot would silently re-partition the name into
89
+ * the wrong schema.)
90
+ */
91
+ export const quoteQualified = name => name.split('.').map(atom => `"${atom.replace(/"/g, '""')}"`).join('.');
92
+ /**
93
+ * A quoted single-atom identifier (no dot-splitting) — used where a bare name is
94
+ * required, e.g. the schema in `CREATE SCHEMA` and the column names below.
95
+ */
96
+ const quoteAtom = name => `"${name.replace(/"/g, '""')}"`;
97
+ /**
98
+ * The checkpoint table's columns, PRE-QUOTED, as one shared descriptor.
99
+ *
100
+ * Shared by the DDL here and by every runtime statement in `projectionStore.ts`,
101
+ * so a column rename cannot half-land: the table it creates and the statements
102
+ * that address it read the same constant. Pre-quoted because `partition` is
103
+ * Postgres's own partitioning vocabulary — it parses unquoted today, but quoting
104
+ * removes the question, and quoting all three keeps the emitted SQL uniform.
105
+ *
106
+ * These names are FIXED and owned by this package (never config-derived), which
107
+ * is why they are plain constants rather than something the caller can influence.
108
+ */
109
+ export const CHECKPOINT_COLUMNS = {
110
+ /** The read model's `ProjectionId`. */
111
+ projection: /*#__PURE__*/quoteAtom('projection'),
112
+ /** The read model's `PartitionId` (`default` while sharding is cut). */
113
+ partition: /*#__PURE__*/quoteAtom('partition'),
114
+ /**
115
+ * The scalar checkpoint: `bigint`, because a `Position` is 64-bit and exceeds
116
+ * JS `number` safety at 2^53. It is read back as a decimal STRING and
117
+ * reconstructed with `BigInt(...)`, never coerced through `number`.
118
+ */
119
+ lastPosition: /*#__PURE__*/quoteAtom('last_position')
120
+ };
121
+ /**
122
+ * The ordered, idempotent DDL that creates (or refreshes) the checkpoint table.
123
+ * Each entry is a `sql.unsafe` statement string; no VALUES are involved (pure
124
+ * DDL), and every identifier is pre-quoted to match the runtime's resolution.
125
+ *
126
+ * Notes on the shape, both load-bearing:
127
+ *
128
+ * - `PRIMARY KEY (projection, partition)` is what makes `readCheckpoint` an index
129
+ * lookup AND supplies the arbiter index the first-commit upsert's
130
+ * `ON CONFLICT (projection, partition)` names. Without it that upsert would not
131
+ * compile, so the key is not merely hygiene.
132
+ * - `last_position bigint NOT NULL` with NO row for a never-committed key: a key
133
+ * reads back as `ORIGIN` by ABSENCE, so "never committed" and "reset" are one
134
+ * state (`resetCheckpoint` deletes the row rather than zeroing it), exactly as
135
+ * the in-memory store's map deletes its entry.
136
+ * - No autovacuum storage parameters, unlike the event store's tables. Those are
137
+ * tuned for an append-heavy, effectively-immutable log; this table is a handful
138
+ * of hot-UPDATEd rows, where the stock thresholds already fire aggressively.
139
+ */
140
+ export const checkpointDdlStatements = names => {
141
+ const table = quoteQualified(names.checkpointTable);
142
+ const schema = quoteAtom(names.schema);
143
+ const columns = CHECKPOINT_COLUMNS;
144
+ return [
145
+ // The schema may already exist (e.g. `public`); create it defensively so a
146
+ // custom schema does not require a separate provisioning step.
147
+ `CREATE SCHEMA IF NOT EXISTS ${schema};`, `CREATE TABLE IF NOT EXISTS ${table} (
148
+ ${columns.projection} text NOT NULL,
149
+ ${columns.partition} text NOT NULL,
150
+ ${columns.lastPosition} bigint NOT NULL,
151
+ PRIMARY KEY (${columns.projection}, ${columns.partition})
152
+ );`];
153
+ };
154
+ /**
155
+ * Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
156
+ * public `ensureCheckpointSchema`) so the same builder feeds both the public
157
+ * migration payload and any internal test setup. Each statement is a separate
158
+ * `sql.unsafe` call so a driver that rejects multi-statement strings still works.
159
+ */
160
+ export const runCheckpointDdl = (sql, names) => checkpointDdlStatements(names).map(statement => sql.unsafe(statement));
161
+ //# sourceMappingURL=ddl.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ddl.js","names":["DEFAULT_SCHEMA","DEFAULT_TABLE_PREFIX","CHECKPOINT_SUFFIX","MAX_IDENTIFIER_BYTES","utf8ByteLength","value","TextEncoder","encode","length","derivedAtoms","config","schema","tablePrefix","oversizedIdentifier","label","atom","bytes","undefined","resolveCheckpointNames","problem","Error","checkpointTable","quoteQualified","name","split","map","replace","join","quoteAtom","CHECKPOINT_COLUMNS","projection","partition","lastPosition","checkpointDdlStatements","names","table","columns","runCheckpointDdl","sql","statement","unsafe"],"sources":["../../../src/internal/ddl.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,74 @@
1
+ import { CHECKPOINT_COLUMNS, quoteQualified } from "./ddl.js";
2
+ /**
3
+ * Build the statement texts for a resolved name set.
4
+ *
5
+ * **The two guard shapes, and why one cannot serve both.** The guarded advance is
6
+ * a compare-and-set on `last_position`, and its correct SQL depends on whether the
7
+ * expected value is `ORIGIN`:
8
+ *
9
+ * - `expected === ORIGIN` needs `upsertFromOrigin`. A never-committed key has NO
10
+ * ROW (a key reads back as `ORIGIN` by absence, so "fresh" and "reset" are one
11
+ * state), so a plain `UPDATE` would match zero rows and report
12
+ * `CheckpointSuperseded` — a fresh read model could never make its FIRST commit.
13
+ * The `ON CONFLICT … DO UPDATE … WHERE chk.last_position = $4` arm keeps the
14
+ * upsert a real guard rather than a blind write: two racers both starting from
15
+ * `ORIGIN` cannot both win, because the second one's arm blocks on the first's
16
+ * uncommitted row, then re-reads it and finds a position that is no longer
17
+ * `ORIGIN`, matching zero rows. That arm also makes "row present at `0`"
18
+ * (an operator zeroing the column by hand instead of deleting the row) behave
19
+ * identically to "row absent", so the two ways of expressing a reset are
20
+ * indistinguishable to a first commit.
21
+ * - `expected > ORIGIN` needs `advance`, the plain `UPDATE`. The upsert would be
22
+ * WRONG here: `ON CONFLICT` guards only the branch where the row exists, so an
23
+ * absent row would be INSERTED unconditionally — a `resetCheckpoint` racing an
24
+ * in-flight commit would be silently undone, the key resurrected at `next`, and
25
+ * the entire backlog the reset existed to replay skipped with no error at all.
26
+ * The `UPDATE`'s zero rows for an absent row is the CORRECT outcome: somebody
27
+ * reset it, so this commit must lose and the runner must re-read.
28
+ *
29
+ * Both shapes `RETURNING` a column so the outcome is a ROW COUNT: zero rows means
30
+ * the guard lost. That mirrors the event store's append, where a conflict is the
31
+ * function's zero-row result and never a caught SQL error — no unique-violation
32
+ * or serialisation-failure path to classify, so a lost guard can never arrive
33
+ * mistagged as an infrastructure fault.
34
+ *
35
+ * The `AS chk` alias exists so the `DO UPDATE` guard can reference the EXISTING
36
+ * row unambiguously: the insert target is schema-qualified, and without an alias
37
+ * the guard would need a three-part column reference.
38
+ */
39
+ export const compileStatements = names => {
40
+ const table = quoteQualified(names.checkpointTable);
41
+ const columns = CHECKPOINT_COLUMNS;
42
+ const byKey = `${columns.projection} = $1 AND ${columns.partition} = $2`;
43
+ return {
44
+ select: `SELECT ${columns.lastPosition}
45
+ FROM ${table}
46
+ WHERE ${byKey}`,
47
+ upsertFromOrigin: `INSERT INTO ${table} AS chk (
48
+ ${columns.projection},
49
+ ${columns.partition},
50
+ ${columns.lastPosition}
51
+ ) VALUES ($1, $2, $3)
52
+ ON CONFLICT (${columns.projection}, ${columns.partition})
53
+ DO UPDATE SET ${columns.lastPosition} = $3
54
+ WHERE chk.${columns.lastPosition} = $4
55
+ RETURNING ${columns.lastPosition}`,
56
+ advance: `UPDATE ${table}
57
+ SET ${columns.lastPosition} = $3
58
+ WHERE ${byKey} AND ${columns.lastPosition} = $4
59
+ RETURNING ${columns.lastPosition}`,
60
+ reset: `DELETE FROM ${table} WHERE ${byKey}`
61
+ };
62
+ };
63
+ /** The `$1`/`$2` key parameters the `select` and the `reset` take. */
64
+ export const keyParams = key => [key.projection, key.partition];
65
+ /**
66
+ * The `$1`-`$4` parameters both guard shapes take, read from named fields.
67
+ *
68
+ * The argument is an object rather than three positional parameters for the reason
69
+ * `CheckpointStatements` gives: `next` and `expected` are interchangeable to the
70
+ * type checker and catastrophic to swap, so the ONE place their order is written
71
+ * out reads each of them by name.
72
+ */
73
+ export const advanceParams = args => [args.key.projection, args.key.partition, args.next, args.expected];
74
+ //# sourceMappingURL=statements.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"statements.js","names":["CHECKPOINT_COLUMNS","quoteQualified","compileStatements","names","table","checkpointTable","columns","byKey","projection","partition","select","lastPosition","upsertFromOrigin","advance","reset","keyParams","key","advanceParams","args","next","expected"],"sources":["../../../src/internal/statements.ts"],"sourcesContent":[null],"mappings":"AAqBA,SACEA,kBAAkB,EAClBC,cAAc,QAET,UAAO;AA2Bd;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,OAAO,MAAMC,iBAAiB,GAC5BC,KAA8B,IACN;EACxB,MAAMC,KAAK,GAAGH,cAAc,CAACE,KAAK,CAACE,eAAe,CAAC;EACnD,MAAMC,OAAO,GAAGN,kBAAkB;EAClC,MAAMO,KAAK,GAAG,GAAGD,OAAO,CAACE,UAAU,aAAaF,OAAO,CAACG,SAAS,OAAO;EAExE,OAAO;IACLC,MAAM,EAAE,UAAUJ,OAAO,CAACK,YAAY;oBACtBP,KAAK;qBACJG,KAAK,EAAE;IAExBK,gBAAgB,EAAE,eAAeR,KAAK;2BACfE,OAAO,CAACE,UAAU;2BAClBF,OAAO,CAACG,SAAS;2BACjBH,OAAO,CAACK,YAAY;;sCAETL,OAAO,CAACE,UAAU,KAAKF,OAAO,CAACG,SAAS;uCACvCH,OAAO,CAACK,YAAY;mCACxBL,OAAO,CAACK,YAAY;mCACpBL,OAAO,CAACK,YAAY,EAAE;IAErDE,OAAO,EAAE,UAAUT,KAAK;oBACRE,OAAO,CAACK,YAAY;sBAClBJ,KAAK,QAAQD,OAAO,CAACK,YAAY;0BAC7BL,OAAO,CAACK,YAAY,EAAE;IAE5CG,KAAK,EAAE,eAAeV,KAAK,UAAUG,KAAK;GAC3C;AACH,CAAC;AAED;AACA,OAAO,MAAMQ,SAAS,GAAIC,GAAkB,IAA6B,CACvEA,GAAG,CAACR,UAAU,EACdQ,GAAG,CAACP,SAAS,CACd;AAED;;;;;;;;AAQA,OAAO,MAAMQ,aAAa,GAAIC,IAI7B,IAA6B,CAC5BA,IAAI,CAACF,GAAG,CAACR,UAAU,EACnBU,IAAI,CAACF,GAAG,CAACP,SAAS,EAClBS,IAAI,CAACC,IAAI,EACTD,IAAI,CAACE,QAAQ,CACd","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": []
4
+ }