@kairos-es/store-postgres 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +167 -0
  3. package/dist/cjs/clients.js +62 -0
  4. package/dist/cjs/clients.js.map +1 -0
  5. package/dist/cjs/config.js +94 -0
  6. package/dist/cjs/config.js.map +1 -0
  7. package/dist/cjs/ensureSchema.js +53 -0
  8. package/dist/cjs/ensureSchema.js.map +1 -0
  9. package/dist/cjs/index.js +45 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/ddl.js +436 -0
  12. package/dist/cjs/internal/ddl.js.map +1 -0
  13. package/dist/cjs/internal/matchSql.js +189 -0
  14. package/dist/cjs/internal/matchSql.js.map +1 -0
  15. package/dist/cjs/internal/readPlan.js +67 -0
  16. package/dist/cjs/internal/readPlan.js.map +1 -0
  17. package/dist/cjs/internal/subscribe.js +120 -0
  18. package/dist/cjs/internal/subscribe.js.map +1 -0
  19. package/dist/cjs/internal/transport.js +66 -0
  20. package/dist/cjs/internal/transport.js.map +1 -0
  21. package/dist/cjs/store.js +256 -0
  22. package/dist/cjs/store.js.map +1 -0
  23. package/dist/dts/clients.d.ts +34 -0
  24. package/dist/dts/clients.d.ts.map +1 -0
  25. package/dist/dts/config.d.ts +58 -0
  26. package/dist/dts/config.d.ts.map +1 -0
  27. package/dist/dts/ensureSchema.d.ts +35 -0
  28. package/dist/dts/ensureSchema.d.ts.map +1 -0
  29. package/dist/dts/index.d.ts +41 -0
  30. package/dist/dts/index.d.ts.map +1 -0
  31. package/dist/dts/internal/ddl.d.ts +174 -0
  32. package/dist/dts/internal/ddl.d.ts.map +1 -0
  33. package/dist/dts/internal/matchSql.d.ts +140 -0
  34. package/dist/dts/internal/matchSql.d.ts.map +1 -0
  35. package/dist/dts/internal/readPlan.d.ts +72 -0
  36. package/dist/dts/internal/readPlan.d.ts.map +1 -0
  37. package/dist/dts/internal/subscribe.d.ts +87 -0
  38. package/dist/dts/internal/subscribe.d.ts.map +1 -0
  39. package/dist/dts/internal/transport.d.ts +116 -0
  40. package/dist/dts/internal/transport.d.ts.map +1 -0
  41. package/dist/dts/store.d.ts +17 -0
  42. package/dist/dts/store.d.ts.map +1 -0
  43. package/dist/esm/clients.js +51 -0
  44. package/dist/esm/clients.js.map +1 -0
  45. package/dist/esm/config.js +87 -0
  46. package/dist/esm/config.js.map +1 -0
  47. package/dist/esm/ensureSchema.js +45 -0
  48. package/dist/esm/ensureSchema.js.map +1 -0
  49. package/dist/esm/index.js +40 -0
  50. package/dist/esm/index.js.map +1 -0
  51. package/dist/esm/internal/ddl.js +424 -0
  52. package/dist/esm/internal/ddl.js.map +1 -0
  53. package/dist/esm/internal/matchSql.js +176 -0
  54. package/dist/esm/internal/matchSql.js.map +1 -0
  55. package/dist/esm/internal/readPlan.js +59 -0
  56. package/dist/esm/internal/readPlan.js.map +1 -0
  57. package/dist/esm/internal/subscribe.js +113 -0
  58. package/dist/esm/internal/subscribe.js.map +1 -0
  59. package/dist/esm/internal/transport.js +56 -0
  60. package/dist/esm/internal/transport.js.map +1 -0
  61. package/dist/esm/package.json +4 -0
  62. package/dist/esm/store.js +249 -0
  63. package/dist/esm/store.js.map +1 -0
  64. package/package.json +35 -0
  65. package/src/clients.ts +69 -0
  66. package/src/config.ts +92 -0
  67. package/src/ensureSchema.ts +46 -0
  68. package/src/index.ts +45 -0
  69. package/src/internal/ddl.ts +599 -0
  70. package/src/internal/matchSql.ts +219 -0
  71. package/src/internal/readPlan.ts +117 -0
  72. package/src/internal/transport.ts +141 -0
  73. package/src/store.ts +413 -0
@@ -0,0 +1,59 @@
1
+ import { classifyServableQuery, ORIGIN } from '@kairos-es/core';
2
+ import { taggedReadSql, typeOnlyReadSql, wildcardReadSql } from "./matchSql.js";
3
+ import { jsonbText, toQueryItemsJson } from "./transport.js";
4
+ /**
5
+ * Compile a servable query into a `FetchPlan`. The SQL comes from the shared
6
+ * `matchSql` builders (the tagged path composes the same emitted match chain as the
7
+ * conflict check, which binds its own placeholder tokens and wraps it differently),
8
+ * with the pre-quoted names embedded and the VALUES bound as `$n` parameters
9
+ * (never interpolated).
10
+ *
11
+ * The `switch` is exhaustive over core's `ServableQueryShape` with NO `default`
12
+ * and no trailing fall-through arm, and what that buys is a COMPILE error where
13
+ * there would otherwise be a silent mis-serve. Core owns that union and this
14
+ * module only consumes it, so a fourth member added there is a change this file
15
+ * cannot see coming: a cascade ending in an unguarded tagged branch would have
16
+ * SERVED that shape as tagged, and both `read` and the `subscribe` poll sharing
17
+ * its plan would have returned the wrong rows — no error raised, nothing failing
18
+ * to typecheck. Every arm returns instead, so the added member leaves this
19
+ * function with a reachable end and a return type that does not admit
20
+ * `undefined`, and the module stops compiling.
21
+ */
22
+ export const compileFetchPlan = (tables, query) => {
23
+ const shape = classifyServableQuery(query);
24
+ switch (shape.kind) {
25
+ case 'wildcard':
26
+ return {
27
+ text: wildcardReadSql(tables.main),
28
+ bind: (after, limit) => [after, limit]
29
+ };
30
+ case 'typeOnly':
31
+ {
32
+ // node-postgres binds a JS array param as a Postgres array literal, so
33
+ // `type = ANY($2)` matches any of the item's types. The item's own list is
34
+ // bound as it stands: a branded `EventType` IS its string to the driver, so
35
+ // nothing here has to unwrap or copy it.
36
+ const types = shape.types;
37
+ return {
38
+ text: typeOnlyReadSql(tables.main),
39
+ bind: (after, limit) => [after, types, limit]
40
+ };
41
+ }
42
+ case 'tagged':
43
+ {
44
+ // `after` ($2) is applied at BOTH CTE stages; `limit` ($3) inside the
45
+ // ordered filtered_ids so it caps by position, not on the outer join.
46
+ const itemsJson = jsonbText(toQueryItemsJson(query));
47
+ return {
48
+ text: taggedReadSql(tables.main, tables.tag),
49
+ bind: (after, limit) => [itemsJson, after, limit]
50
+ };
51
+ }
52
+ }
53
+ };
54
+ /**
55
+ * Run a compiled plan for a given `after`/`limit`, honouring `after` (exclusive;
56
+ * absent → `0`) and `limit` (`NULL` = no limit), ordered by `id ASC`.
57
+ */
58
+ export const runFetch = (sql, plan, after, limit) => sql.unsafe(plan.text, plan.bind(after ?? ORIGIN, limit ?? null));
59
+ //# sourceMappingURL=readPlan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readPlan.js","names":["classifyServableQuery","ORIGIN","taggedReadSql","typeOnlyReadSql","wildcardReadSql","jsonbText","toQueryItemsJson","compileFetchPlan","tables","query","shape","kind","text","main","bind","after","limit","types","itemsJson","tag","runFetch","sql","plan","unsafe"],"sources":["../../../src/internal/readPlan.ts"],"sourcesContent":[null],"mappings":"AAyBA,SAASA,qBAAqB,EAAEC,MAAM,QAAoB,iBAAiB;AAE3E,SAASC,aAAa,EAAEC,eAAe,EAAEC,eAAe,QAAQ,eAAY;AAC5E,SAAwBC,SAAS,EAAEC,gBAAgB,QAAQ,gBAAa;AA0BxE;;;;;;;;;;;;;;;;;;AAkBA,OAAO,MAAMC,gBAAgB,GAAGA,CAC9BC,MAAkB,EAClBC,KAAY,KACC;EACb,MAAMC,KAAK,GAAGV,qBAAqB,CAACS,KAAK,CAAC;EAC1C,QAAQC,KAAK,CAACC,IAAI;IAChB,KAAK,UAAU;MACb,OAAO;QACLC,IAAI,EAAER,eAAe,CAACI,MAAM,CAACK,IAAI,CAAC;QAClCC,IAAI,EAAEA,CAACC,KAAK,EAAEC,KAAK,KAAK,CAACD,KAAK,EAAEC,KAAK;OACtC;IACH,KAAK,UAAU;MAAE;QACf;QACA;QACA;QACA;QACA,MAAMC,KAAK,GAAGP,KAAK,CAACO,KAAK;QACzB,OAAO;UACLL,IAAI,EAAET,eAAe,CAACK,MAAM,CAACK,IAAI,CAAC;UAClCC,IAAI,EAAEA,CAACC,KAAK,EAAEC,KAAK,KAAK,CAACD,KAAK,EAAEE,KAAK,EAAED,KAAK;SAC7C;MACH;IACA,KAAK,QAAQ;MAAE;QACb;QACA;QACA,MAAME,SAAS,GAAGb,SAAS,CAACC,gBAAgB,CAACG,KAAK,CAAC,CAAC;QACpD,OAAO;UACLG,IAAI,EAAEV,aAAa,CAACM,MAAM,CAACK,IAAI,EAAEL,MAAM,CAACW,GAAG,CAAC;UAC5CL,IAAI,EAAEA,CAACC,KAAK,EAAEC,KAAK,KAAK,CAACE,SAAS,EAAEH,KAAK,EAAEC,KAAK;SACjD;MACH;EACF;AACF,CAAC;AAED;;;;AAIA,OAAO,MAAMI,QAAQ,GAAGA,CACtBC,GAAwB,EACxBC,IAAe,EACfP,KAA2B,EAC3BC,KAA4B,KAE5BK,GAAG,CAACE,MAAM,CAAWD,IAAI,CAACV,IAAI,EAAEU,IAAI,CAACR,IAAI,CAACC,KAAK,IAAId,MAAM,EAAEe,KAAK,IAAI,IAAI,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1,113 @@
1
+ import { ORIGIN, ReadLimit } from '@kairos-es/core';
2
+ import { Chunk, Effect, Option, Queue, Schedule, Stream } from 'effect';
3
+ /**
4
+ * A short catch-up page size default for the reconcile loop. Reads page by page
5
+ * until a short page signals the tail is drained, so a large backlog is streamed
6
+ * in bounded chunks rather than one huge SELECT. Overridable via
7
+ * `config.catchUpPageSize`.
8
+ */
9
+ export const DEFAULT_CATCH_UP_PAGE = 512;
10
+ /**
11
+ * The default upper bound a live subscriber waits for a wake before re-reading
12
+ * from its cursor ANYWAY (milliseconds; overridable via `config.pollInterval`).
13
+ * This periodic position-based poll — not `NOTIFY` — is the correctness
14
+ * guarantee: every committed position is reconciled within this interval
15
+ * regardless of whether its `NOTIFY` was heard, which covers a `NOTIFY` fired
16
+ * before `LISTEN` was fully established, a coalesced/lost `NOTIFY`, and a listen
17
+ * connection that dropped SILENTLY (the `@effect/sql-pg` listen client swallows
18
+ * connection errors and does not auto-reconnect). `NOTIFY` only lowers delivery
19
+ * latency below this interval. Upstream `postgres_tt.py` uses the same 1-second
20
+ * fallback poll (`conn.wait(..., interval=1)`).
21
+ */
22
+ export const DEFAULT_POLL_INTERVAL_MILLIS = 1000;
23
+ /**
24
+ * Retry a TRANSIENT poll fault (a `SqlError` from one reconcile read) on a capped
25
+ * exponential backoff rather than letting `Stream.orDie` kill a subscription that
26
+ * may have been alive for days over a single connection blip. A fault that
27
+ * PERSISTS past the cap propagates (the decorator dies it) — infrastructure stays
28
+ * off the contract channel while the stream survives ordinary transients.
29
+ */
30
+ const POLL_RETRY_SCHEDULE = /*#__PURE__*/Schedule.exponential('100 millis', 2).pipe(/*#__PURE__*/Schedule.union(/*#__PURE__*/Schedule.spaced('2 seconds')), /*#__PURE__*/Schedule.intersect(/*#__PURE__*/Schedule.recurs(20)));
31
+ /**
32
+ * Build the subscription `Stream`: catch up from `after`, then track the live
33
+ * tail. The cursor threads through the `unfoldChunkEffect` unfold state, starting
34
+ * at `after` and advancing to the last delivered position each batch; the stream
35
+ * never completes on its own (always `Option.some`), so the enclosing scope
36
+ * interrupts it on close. A `SqlError` mid-stream stays on the channel;
37
+ * `makeContractStore` dies it (subscribe is `E = never` on the contract).
38
+ */
39
+ export const subscribeStream = (deps, after) => Stream.unwrapScoped(Effect.gen(function* () {
40
+ // The wake primitive: a sliding(1) queue holding at most one pending
41
+ // signal. The listener `offer`s an opaque wake on every NOTIFY; the poll
42
+ // loop `take`s (with a timeout). The lost-wakeup guard is STRUCTURAL —
43
+ // drain the queue BEFORE reading, so a NOTIFY that lands mid-read leaves
44
+ // a fresh signal that makes the next `take` return at once rather than
45
+ // sleeping through a real change.
46
+ const wake = yield* Queue.sliding(1);
47
+ // Tap every notification into the wake queue. If the listen stream DOES
48
+ // surface an error, re-subscribe with capped-exponential backoff. In
49
+ // practice the @effect/sql-pg listen client usually swallows a dropped
50
+ // connection SILENTLY rather than erroring, so this retry rarely fires —
51
+ // the poll fallback below, not this retry, is the real reconnect guarantee:
52
+ // the reconcile loop re-reads from the cursor on every poll, so a silently-
53
+ // dropped (and later re-established) listener loses no positions.
54
+ const listener = deps.listen.pipe(Stream.tap(() => Queue.offer(wake, undefined)), Stream.retry(Schedule.exponential('50 millis').pipe(Schedule.union(Schedule.spaced('1 seconds')))));
55
+ // Drain the listener into the wake queue on a background fibre; the scope
56
+ // releases it. We never read its elements directly — every notification is
57
+ // an opaque wake signal.
58
+ yield* listener.pipe(Stream.runDrain, Effect.forkScoped);
59
+ // Reconcile: page-read from `from` until a short page, advancing to the last
60
+ // delivered position. Retries a TRANSIENT poll fault on a capped schedule so
61
+ // a single connection blip does not kill a days-long subscription; a
62
+ // persistent fault propagates and dies.
63
+ const drainFrom = from => Effect.gen(function* () {
64
+ const collected = [];
65
+ let cursor = from;
66
+ // Loop pages until a short page signals the tail is drained.
67
+ while (true) {
68
+ const events = yield* deps.fetch(cursor, ReadLimit.make(deps.catchUpPage));
69
+ for (const event of events) {
70
+ collected.push(event);
71
+ }
72
+ const lastEvent = events.at(-1);
73
+ if (lastEvent === undefined || events.length < deps.catchUpPage) {
74
+ break;
75
+ }
76
+ cursor = lastEvent.position;
77
+ }
78
+ const last = collected.at(-1)?.position ?? from;
79
+ return {
80
+ events: collected,
81
+ last
82
+ };
83
+ }).pipe(Effect.retry(POLL_RETRY_SCHEDULE));
84
+ /**
85
+ * Produce the NEXT non-empty batch from a cursor position, together with
86
+ * the advanced cursor — a pure `Position → Effect<[Chunk, next]>`. The
87
+ * `> boundary` de-dup is enforced by `fetch`'s exclusive lower bound, so a
88
+ * live re-read after a wake never re-emits an already-delivered position.
89
+ *
90
+ * Drain the wake queue BEFORE reading (lost-wakeup guard), then drain from
91
+ * the cursor. If a drain yields nothing, wait for a wake but only up to the
92
+ * poll interval, then loop and re-read ANYWAY — the periodic position-based
93
+ * poll that makes delivery correct independently of NOTIFY. This effect only
94
+ * completes by returning a non-empty chunk; the scope interrupts it on close.
95
+ */
96
+ const nextBatch = from => Effect.gen(function* () {
97
+ while (true) {
98
+ yield* Queue.takeAll(wake);
99
+ const {
100
+ events,
101
+ last
102
+ } = yield* drainFrom(from);
103
+ if (events.length > 0) {
104
+ return [Chunk.fromIterable(events), last];
105
+ }
106
+ // Nothing new: block on a wake, but only up to the poll interval — on
107
+ // timeout we fall through and the loop re-reads regardless.
108
+ yield* Effect.ignore(Effect.timeout(Queue.take(wake), deps.pollInterval));
109
+ }
110
+ });
111
+ return Stream.unfoldChunkEffect(after ?? ORIGIN, from => Effect.map(nextBatch(from), Option.some));
112
+ }));
113
+ //# sourceMappingURL=subscribe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscribe.js","names":["ORIGIN","ReadLimit","Chunk","Effect","Option","Queue","Schedule","Stream","DEFAULT_CATCH_UP_PAGE","DEFAULT_POLL_INTERVAL_MILLIS","POLL_RETRY_SCHEDULE","exponential","pipe","union","spaced","intersect","recurs","subscribeStream","deps","after","unwrapScoped","gen","wake","sliding","listener","listen","tap","offer","undefined","retry","runDrain","forkScoped","drainFrom","from","collected","cursor","events","fetch","make","catchUpPage","event","push","lastEvent","at","length","position","last","nextBatch","takeAll","fromIterable","ignore","timeout","take","pollInterval","unfoldChunkEffect","map","some"],"sources":["../../../src/internal/subscribe.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
@@ -0,0 +1,56 @@
1
+ import { DateTime, Encoding } from 'effect';
2
+ /**
3
+ * Serialise a value to a JSON STRING for transport as a `::jsonb`-cast bound
4
+ * parameter.
5
+ *
6
+ * WHY not `sql.json(value)`: `@effect/sql-pg`'s `json` helper binds the RAW
7
+ * value, and node-postgres serialises a JS *array* bound param as a Postgres
8
+ * ARRAY literal (`{…}`), not JSON — so `sql.json([...])::jsonb` fails with a
9
+ * `22P02` invalid-JSON error (an array literal is not valid JSON). Passing a
10
+ * pre-stringified JSON string and casting `::jsonb` sends plain text that
11
+ * Postgres parses as JSON, which round-trips reliably for both objects and
12
+ * arrays. Kept as a named helper so every JSON-transport call site is consistent
13
+ * and the reasoning lives in one place.
14
+ */
15
+ export const jsonbText = value => JSON.stringify(value);
16
+ /** Serialise a `Query`'s items into their transport JSON shape. */
17
+ export const toQueryItemsJson = query => query.items.map(item => ({
18
+ types: [...item.types],
19
+ tags: [...item.tags]
20
+ }));
21
+ /** Serialise one core `DcbEvent` into its transport JSON shape. */
22
+ export const toEventJson = event => ({
23
+ type: event.type,
24
+ // `Encoding.encodeBase64` (from `effect`) keeps this dependency-free and pure
25
+ // — no `Buffer`, so no `@types/node` and no browser/runtime coupling.
26
+ data: Encoding.encodeBase64(event.data),
27
+ tags: [...event.tags],
28
+ uuid: event.uuid,
29
+ occurred_at: DateTime.formatIso(event.occurredAt)
30
+ });
31
+ /**
32
+ * Map a raw main-table row to a core `SequencedEvent`. `data` NULL → empty bytes
33
+ * (a payload-less event round-trips to a zero-length `Uint8Array`); `occurred_at`
34
+ * Date → `DateTime.Utc`; `id` string → branded `Position`.
35
+ *
36
+ * The branded scalars (`type`, `tags`, `uuid`, `position`) are minted by trusted
37
+ * `as`-cast, NOT by re-running `EventType.make` / `Tag.make` etc. This is
38
+ * deliberate: these bytes were validated at write time, and the store schema is
39
+ * intentionally LAX on read (ADR-0006 store-laxness — hand-assembled or pre-ADR
40
+ * events must remain readable), so re-validating here could REJECT a legitimately
41
+ * stored value and break that contract. It mirrors the in-memory oracle, which
42
+ * likewise mints branded positions by cast at its trusted allocation boundary
43
+ * (`inMemory.ts`). The read boundary is the analogous trusted deserialisation
44
+ * point.
45
+ */
46
+ export const rowToSequenced = row => ({
47
+ event: {
48
+ type: row.type,
49
+ data: row.data ?? new Uint8Array(0),
50
+ tags: row.tags,
51
+ uuid: row.uuid,
52
+ occurredAt: DateTime.unsafeFromDate(row.occurred_at)
53
+ },
54
+ position: BigInt(row.id)
55
+ });
56
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","names":["DateTime","Encoding","jsonbText","value","JSON","stringify","toQueryItemsJson","query","items","map","item","types","tags","toEventJson","event","type","data","encodeBase64","uuid","occurred_at","formatIso","occurredAt","rowToSequenced","row","Uint8Array","unsafeFromDate","position","BigInt","id"],"sources":["../../../src/internal/transport.ts"],"sourcesContent":[null],"mappings":"AAsBA,SAASA,QAAQ,EAAEC,QAAQ,QAAQ,QAAQ;AAG3C;;;;;;;;;;;;;AAaA,OAAO,MAAMC,SAAS,GAAIC,KAAc,IAAaC,IAAI,CAACC,SAAS,CAACF,KAAK,CAAC;AAQ1E;AACA,OAAO,MAAMG,gBAAgB,GAAIC,KAAY,IAC3CA,KAAK,CAACC,KAAK,CAACC,GAAG,CAAEC,IAAI,KAAM;EACzBC,KAAK,EAAE,CAAC,GAAGD,IAAI,CAACC,KAAK,CAAC;EACtBC,IAAI,EAAE,CAAC,GAAGF,IAAI,CAACE,IAAI;CACpB,CAAC,CAAC;AA4BL;AACA,OAAO,MAAMC,WAAW,GAAIC,KAAe,KAAiB;EAC1DC,IAAI,EAAED,KAAK,CAACC,IAAI;EAChB;EACA;EACAC,IAAI,EAAEf,QAAQ,CAACgB,YAAY,CAACH,KAAK,CAACE,IAAI,CAAC;EACvCJ,IAAI,EAAE,CAAC,GAAGE,KAAK,CAACF,IAAI,CAAC;EACrBM,IAAI,EAAEJ,KAAK,CAACI,IAAI;EAChBC,WAAW,EAAEnB,QAAQ,CAACoB,SAAS,CAACN,KAAK,CAACO,UAAU;CACjD,CAAC;AA4BF;;;;;;;;;;;;;;;AAeA,OAAO,MAAMC,cAAc,GAAIC,GAAa,KAAsB;EAChET,KAAK,EAAE;IACLC,IAAI,EAAEQ,GAAG,CAACR,IAAwB;IAClCC,IAAI,EAAEO,GAAG,CAACP,IAAI,IAAI,IAAIQ,UAAU,CAAC,CAAC,CAAC;IACnCZ,IAAI,EAAEW,GAAG,CAACX,IAAmC;IAC7CM,IAAI,EAAEK,GAAG,CAACL,IAAwB;IAClCG,UAAU,EAAErB,QAAQ,CAACyB,cAAc,CAACF,GAAG,CAACJ,WAAW;GACpD;EACDO,QAAQ,EAAEC,MAAM,CAACJ,GAAG,CAACK,EAAE;CACxB,CAAC","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "sideEffects": []
4
+ }
@@ -0,0 +1,249 @@
1
+ import { AppendConditionFailed, DcbEventStore, DEFAULT_CATCH_UP_PAGE_SIZE, DEFAULT_POLL_INTERVAL_MILLIS, makeContractStore, ORIGIN, subscribeStream } from '@kairos-es/core';
2
+ import { Effect, Layer, Stream } from 'effect';
3
+ import { PgClientDirect, PgClientPooled } from "./clients.js";
4
+ import { decodePostgresStoreConfig } from "./config.js";
5
+ import { quoteQualified, resolveNames } from "./internal/ddl.js";
6
+ import { compileFetchPlan, runFetch } from "./internal/readPlan.js";
7
+ import { jsonbText, rowToSequenced, toEventJson, toQueryItemsJson } from "./internal/transport.js";
8
+ /**
9
+ * Set `lock_timeout` for the current transaction via a BOUND parameter.
10
+ *
11
+ * `SET LOCAL` cannot take a bound parameter, but `set_config(setting, value,
12
+ * is_local)` is its exact bound-parameter equivalent (`is_local = true` ≡ `SET
13
+ * LOCAL`) — so the timeout value is a parameter, never interpolated into SQL
14
+ * text. The value is `'<seconds>s'` (Postgres parses the `s` unit); `'0s'` = no
15
+ * timeout. Runs inside the append transaction so it scopes to that call only.
16
+ */
17
+ const setLocalLockTimeout = (sql, lockTimeout) => sql`SELECT set_config('lock_timeout', ${`${lockTimeout}s`}, true)`;
18
+ /**
19
+ * Build the engine MECHANICS (a `RawDcbEventStore`) over the two clients and a
20
+ * resolved name set. Contract policy (`assertServableQuery`, `ReadLimit` decode,
21
+ * error-vs-defect classification) is NOT here — `makeContractStore` adds it
22
+ * uniformly, so this engine surfaces raw `SqlError`s and the contract conflict.
23
+ */
24
+ const make = (config, names) => Effect.gen(function* () {
25
+ const pooled = yield* PgClientPooled;
26
+ const direct = yield* PgClientDirect;
27
+ const lockTimeout = config.lockTimeout ?? 0;
28
+ const pollInterval = config.pollInterval ?? DEFAULT_POLL_INTERVAL_MILLIS;
29
+ const catchUpPageSize = config.catchUpPageSize ?? DEFAULT_CATCH_UP_PAGE_SIZE;
30
+ // The pre-quoted table names every read plan embeds (resolved once).
31
+ const tables = {
32
+ main: quoteQualified(names.mainTable),
33
+ tag: quoteQualified(names.tagTable)
34
+ };
35
+ // --- read -------------------------------------------------------------
36
+ /**
37
+ * The global last position ignoring any filter — the head of a no-limit
38
+ * read. Empty store → ORIGIN.
39
+ *
40
+ * A VALUE over the resolved `pooled`, and deliberately NOT a function of a
41
+ * client — do not re-introduce the parameter. There is only one client it
42
+ * could take: `direct` exists solely for `subscribe`'s `listen` (see
43
+ * `clients.ts`), so a client parameter would advertise a selection seam the
44
+ * engine does not have.
45
+ *
46
+ * Nor would such a parameter be load-bearing for the REPEATABLE READ
47
+ * transaction this runs inside, which is the trap worth naming, since a
48
+ * closed-over client LOOKS like it must escape to its own snapshot.
49
+ * `@effect/sql` resolves a statement's connection when the statement RUNS,
50
+ * from the FIBRE CONTEXT rather than from the client value: `withTransaction`
51
+ * acquires one connection and `Effect.locally`s it into
52
+ * `FiberRef.currentContext` under its `TransactionConnection` tag, and every
53
+ * statement a client builds resolves through a `getConnection` that reads
54
+ * that tag first, falling back to the pool acquirer only when it is absent
55
+ * (`@effect/sql@0.52` `src/internal/client.ts`). A statement therefore joins
56
+ * a transaction by being RUN inside it, and passing the client along the call
57
+ * chain neither adds to that nor is required by it. `read-postgres`'s
58
+ * `pinnedConnection` test observes the same fact from the SERVER: two
59
+ * statements issued inside one transaction report one `pg_backend_pid()`.
60
+ *
61
+ * Built once at layer construction and re-run per read, which a statement
62
+ * supports: it carries no mutable state and recompiles on every execution.
63
+ */
64
+ const globalHead = pooled`
65
+ SELECT MAX(id) AS max FROM ${pooled(names.mainTable)}
66
+ `.pipe(Effect.map(rows => {
67
+ const max = rows[0]?.max;
68
+ return max === null || max === undefined ? ORIGIN : BigInt(max);
69
+ }));
70
+ /**
71
+ * The read MECHANICS: the rows a query selects, plus the head a decision
72
+ * model may then append under. `read` below only shapes the pair into a
73
+ * `ReadResponse`, so the two snapshot regimes argued for here stay in one
74
+ * place.
75
+ *
76
+ * Named for what it returns rather than for what it reads through: it closes
77
+ * over `pooled`, and takes no client, for the reason given on `globalHead`.
78
+ */
79
+ const readRowsAndHead = (query, options) => Effect.gen(function* () {
80
+ // No validation here — the query and limit were validated once at the
81
+ // `makeContractStore` boundary, so the per-read re-validation is gone.
82
+ const plan = compileFetchPlan(tables, query);
83
+ // Limited read: `head` is the last RETURNED position, taken from the
84
+ // SAME single statement, so rows and head are already one consistent
85
+ // snapshot (or ORIGIN when nothing is returned). No transaction needed —
86
+ // a single statement sees a single snapshot.
87
+ if (options?.limit !== undefined) {
88
+ const rows = yield* runFetch(pooled, plan, options.after, options.limit);
89
+ const lastRow = rows.at(-1);
90
+ const head = lastRow === undefined ? ORIGIN : BigInt(lastRow.id);
91
+ return {
92
+ rows,
93
+ head
94
+ };
95
+ }
96
+ // No-limit read: `head` is the GLOBAL last position (ignoring the query
97
+ // filter), which needs a SEPARATE `MAX(id)` statement. Rows and head
98
+ // MUST share one MVCC snapshot: under the default READ COMMITTED each
99
+ // statement snapshots independently, so a concurrent append committing
100
+ // between the row fetch and the `MAX(id)` would push `head` PAST an
101
+ // event absent from `events` — and a decision model appending under
102
+ // `{ after: head }` would then silently miss that event as a conflict,
103
+ // breaking optimistic concurrency. A REPEATABLE READ, READ ONLY
104
+ // transaction gives both statements a single snapshot, matching the
105
+ // in-memory oracle's atomic (mutex-guarded) read. Readers take only
106
+ // ACCESS SHARE, so this never contends with the exclusive-lock append.
107
+ return yield* pooled.withTransaction(Effect.gen(function* () {
108
+ yield* pooled.unsafe('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY');
109
+ const rows = yield* runFetch(pooled, plan, options?.after, undefined);
110
+ const head = yield* globalHead;
111
+ return {
112
+ rows,
113
+ head
114
+ };
115
+ }));
116
+ });
117
+ const read = (query, options) => readRowsAndHead(query, options).pipe(Effect.map(({
118
+ rows,
119
+ head
120
+ }) => ({
121
+ events: Stream.fromIterable(rows.map(rowToSequenced)),
122
+ head
123
+ })));
124
+ /**
125
+ * The ONE transaction frame every append rides: open a transaction, apply the
126
+ * per-call `set_config('lock_timeout', …, true)`, then run the caller's
127
+ * `SELECT <fn>(…)`. Both the conditional and unconditional paths go through
128
+ * here, so the lock-timeout frame can never diverge between them — the
129
+ * function's LOCK/check/insert and the timeout are one atomic unit, and the
130
+ * conflict (zero rows) vs head (`MAX(id)`) is read from the returned rows by
131
+ * the caller, never from a caught error.
132
+ */
133
+ const callAppendFn = select => pooled.withTransaction(setLocalLockTimeout(pooled, lockTimeout).pipe(Effect.zipRight(select)));
134
+ const append = (events, condition) => {
135
+ const eventsJson = events.map(toEventJson);
136
+ // Raw mechanics: a CONFLICT is `AppendConditionFailed`; a `SqlError` (incl.
137
+ // a `55P03` lock-timeout abort) rides the channel too. `makeContractStore`
138
+ // classifies — conflict stays, everything else becomes a defect.
139
+ return Effect.gen(function* () {
140
+ if (condition === undefined) {
141
+ // No guard, but the unconditional twin STILL takes the exclusive lock
142
+ // (see `unconditionalAppendBody`). That lock on BOTH append paths is how
143
+ // this engine discharges the subscribe machine's ascending-commit-order
144
+ // precondition (`SubscribeMachine.ts` argues it): without it two
145
+ // concurrent unconditional writers can commit bigserial ids out of
146
+ // order, letting `subscribe` skip the later-committing lower id and
147
+ // `head` overtake an in-flight position. Sharing `callAppendFn` with the
148
+ // conditional path guarantees the lock is scoped to the call and the
149
+ // configured timeout applies identically to both append paths.
150
+ const rows = yield* callAppendFn(pooled`
151
+ SELECT ${pooled(names.appendUnconditionalFn)}(
152
+ ${jsonbText(eventsJson)}::jsonb
153
+ ) AS max
154
+ `);
155
+ return headFromRows(rows);
156
+ }
157
+ const queryItems = toQueryItemsJson(condition.failIfEventsMatch);
158
+ const afterId = condition.after === undefined ? null : condition.after;
159
+ // The function returns MAX(id) on success, ZERO rows on conflict — the
160
+ // conflict is the row count, never a caught error.
161
+ const rows = yield* callAppendFn(pooled`
162
+ SELECT ${pooled(names.appendFn)}(
163
+ ${jsonbText(queryItems)}::jsonb,
164
+ ${afterId},
165
+ ${jsonbText(eventsJson)}::jsonb
166
+ ) AS max
167
+ `);
168
+ // Empty result set = conflict.
169
+ if (rows.length === 0) {
170
+ return yield* new AppendConditionFailed({
171
+ condition
172
+ });
173
+ }
174
+ return headFromRows(rows);
175
+ });
176
+ };
177
+ /**
178
+ * Extract the new head `Position` from the function's single result row. The
179
+ * function returns exactly one row (`MAX(id)`) on a successful insert; that
180
+ * value is the last new position (read-your-writes).
181
+ */
182
+ const headFromRows = rows => {
183
+ const max = rows[0]?.max;
184
+ if (max === null || max === undefined) {
185
+ // Defensive: a successful unconditional/`NOT conflict` insert always
186
+ // yields a non-null MAX. A null here means nothing was inserted, which
187
+ // for an append (always ≥1 event) is a broken invariant → die.
188
+ throw new Error('kairos-es/store-postgres: append returned no head position');
189
+ }
190
+ return BigInt(max);
191
+ };
192
+ // --- subscribe --------------------------------------------------------
193
+ /**
194
+ * Wire core's subscribe machine to the live clients: `fetch` is the
195
+ * per-subscription compiled read plan run against the pooled client and mapped
196
+ * to `SequencedEvent`s; `listen` is the direct client's ref-counted, non-pooled
197
+ * LISTEN on this store's channel (the Neon-safe path — `clients.ts` owns why
198
+ * there are two clients at all). The plan is compiled ONCE here so the poll
199
+ * loop re-binds only the cursor.
200
+ *
201
+ * The wake source is `NOTIFY`, and it is a LATENCY optimisation only: the
202
+ * machine's periodic position-based poll is what makes delivery correct, which
203
+ * matters more here than the general argument for it suggests. The
204
+ * `@effect/sql-pg` listen client SWALLOWS connection errors and does not
205
+ * auto-reconnect, so a dropped LISTEN typically goes quiet rather than
206
+ * erroring — the machine's listen-side retry then never fires, and the poll is
207
+ * the ONLY thing that recovers the subscription. The same silence is what a
208
+ * Neon pooled endpoint produces if `PgClientDirect` is misconfigured: the
209
+ * pooler drops `LISTEN`/`NOTIFY` without complaint and delivery degrades to
210
+ * poll-rate rather than failing, which is why that trap is a deployment-time
211
+ * wiring choice and not a runtime error.
212
+ *
213
+ * `E` is `SqlError.SqlError` — both seams fail with it, so the machine's single
214
+ * error parameter carries it straight through to `makeContractStore`.
215
+ */
216
+ const subscribe = (query, after) => {
217
+ const plan = compileFetchPlan(tables, query);
218
+ return subscribeStream({
219
+ fetch: (from, limit) => runFetch(pooled, plan, from, limit).pipe(Effect.map(rows => rows.map(rowToSequenced))),
220
+ listen: direct.listen(names.channel),
221
+ pollInterval,
222
+ catchUpPageSize
223
+ }, after);
224
+ };
225
+ return {
226
+ read,
227
+ append,
228
+ subscribe
229
+ };
230
+ });
231
+ /**
232
+ * The Postgres `DcbEventStore` layer factory. Builds the engine over
233
+ * `PgClientPooled` (reads/append/notify) and `PgClientDirect` (the subscribe
234
+ * listener). The caller wires those two client layers — pointing `PgClientDirect`
235
+ * at a direct (non-pooler) endpoint on Neon, or at the same URL as `PgClientPooled`
236
+ * everywhere else.
237
+ *
238
+ * `ensureSchema(config)` must have been run (by the caller's migrator or at
239
+ * boot) before the layer is used; the layer itself performs no DDL, so it stays
240
+ * a pure runtime dependency requiring only the two clients.
241
+ */
242
+ export const layer = (config = {}) => {
243
+ // Decode through the config schema so a malformed config (empty/dotted atom,
244
+ // negative/non-finite timeout, over-63-byte derived name) fails LOUDLY at
245
+ // construction rather than late on the first append.
246
+ const decoded = decodePostgresStoreConfig(config);
247
+ return Layer.effect(DcbEventStore, Effect.map(make(decoded, resolveNames(decoded)), makeContractStore));
248
+ };
249
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","names":["AppendConditionFailed","DcbEventStore","DEFAULT_CATCH_UP_PAGE_SIZE","DEFAULT_POLL_INTERVAL_MILLIS","makeContractStore","ORIGIN","subscribeStream","Effect","Layer","Stream","PgClientDirect","PgClientPooled","decodePostgresStoreConfig","quoteQualified","resolveNames","compileFetchPlan","runFetch","jsonbText","rowToSequenced","toEventJson","toQueryItemsJson","setLocalLockTimeout","sql","lockTimeout","make","config","names","gen","pooled","direct","pollInterval","catchUpPageSize","tables","main","mainTable","tag","tagTable","globalHead","pipe","map","rows","max","undefined","BigInt","readRowsAndHead","query","options","plan","limit","after","lastRow","at","head","id","withTransaction","unsafe","read","events","fromIterable","callAppendFn","select","zipRight","append","condition","eventsJson","appendUnconditionalFn","headFromRows","queryItems","failIfEventsMatch","afterId","appendFn","length","Error","subscribe","fetch","from","listen","channel","layer","decoded","effect"],"sources":["../../src/store.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@kairos-es/store-postgres",
3
+ "version": "0.0.0",
4
+ "description": "Postgres DcbEventStore backend 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/store-postgres"
10
+ },
11
+ "sideEffects": [],
12
+ "homepage": "https://github.com/neverbland/kairos-es",
13
+ "peerDependencies": {
14
+ "@effect/experimental": "^0.61.0",
15
+ "@effect/platform": "^0.97.0",
16
+ "@effect/sql": "^0.52.0",
17
+ "@effect/sql-pg": "^0.53.0",
18
+ "effect": "^3.22.0",
19
+ "@kairos-es/core": "^0.0.0"
20
+ },
21
+ "main": "./dist/cjs/index.js",
22
+ "module": "./dist/esm/index.js",
23
+ "types": "./dist/dts/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/dts/index.d.ts",
27
+ "import": "./dist/esm/index.js",
28
+ "default": "./dist/cjs/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "typesVersions": {
33
+ "*": {}
34
+ }
35
+ }
package/src/clients.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The TWO store-owned Postgres clients — the "Neon trap" mitigation (ADR-0002).
3
+ *
4
+ * `@effect/sql-pg` exposes a single `PgClient` (and `SqlClient`) tag; a
5
+ * `PgClient.layer` claims BOTH. If we built two `PgClient.layer`s they would
6
+ * collide on that one tag — the second would shadow the first. So instead we
7
+ * define two DISTINCT `Context.Tag`s, each HOLDING a `PgClient` value:
8
+ *
9
+ * - `PgClientPooled` — the pooled client used for reads, the append function,
10
+ * and `notify`. Backed by a `pg.Pool`; connections are recycled per query.
11
+ * - `PgClientDirect` — a client used ONLY by `subscribe`'s `listen`. Its
12
+ * built-in `listen` constructs a STANDALONE `pg.Client` from the pool's
13
+ * `options`, inheriting the pool's host. On a Neon POOLED endpoint
14
+ * (`…-pooler…`) that standalone connection lands on the PgBouncer front door,
15
+ * which SILENTLY DROPS `LISTEN`/`NOTIFY` — the subscription would simply never
16
+ * wake. Pointing `PgClientDirect` at Neon's DIRECT (non-pooler) endpoint fixes
17
+ * it. `subscribe` is the ONLY method that touches `PgClientDirect`.
18
+ *
19
+ * For a plain (non-Neon) Postgres both tags point at the same URL; the split
20
+ * costs nothing there. The two-tag design is what makes the Neon fix a
21
+ * deployment-time wiring choice rather than a code change.
22
+ */
23
+ import { Reactivity } from '@effect/experimental'
24
+ import type { SqlError } from '@effect/sql'
25
+ import { PgClient } from '@effect/sql-pg'
26
+ import { Context, Layer } from 'effect'
27
+
28
+ /**
29
+ * The pooled client tag: reads, the append function, and `notify`. Holds a
30
+ * `PgClient` value rather than being a `PgClient.layer`, so it never collides
31
+ * with the direct client on the shared `PgClient`/`SqlClient` tag.
32
+ */
33
+ export class PgClientPooled extends Context.Tag(
34
+ '@kairos-es/store-postgres/PgClientPooled',
35
+ )<PgClientPooled, PgClient.PgClient>() {}
36
+
37
+ /**
38
+ * The direct (non-pooler) client tag: the `subscribe` listener ONLY. Point this
39
+ * at a direct endpoint on Neon so `LISTEN`/`NOTIFY` is not dropped by the pooler.
40
+ */
41
+ export class PgClientDirect extends Context.Tag(
42
+ '@kairos-es/store-postgres/PgClientDirect',
43
+ )<PgClientDirect, PgClient.PgClient>() {}
44
+
45
+ /**
46
+ * Build a `PgClientPooled` layer from a `PgClient` config. We construct the
47
+ * client via `PgClient.make` (scoped) and place it in our OWN tag, then provide
48
+ * `Reactivity.layer` (which `PgClient.make` requires) — exactly what the
49
+ * built-in `PgClient.layer` does internally, minus the collision-prone claim on
50
+ * the shared `PgClient` tag.
51
+ */
52
+ export const pooledLayer = (
53
+ config: PgClient.PgClientConfig,
54
+ ): Layer.Layer<PgClientPooled, SqlError.SqlError> =>
55
+ Layer.scoped(PgClientPooled, PgClient.make(config)).pipe(
56
+ Layer.provide(Reactivity.layer),
57
+ )
58
+
59
+ /**
60
+ * Build a `PgClientDirect` layer from a `PgClient` config. Same construction as
61
+ * `pooledLayer`; the two differ only in which endpoint the caller points them
62
+ * at (and that only matters on Neon).
63
+ */
64
+ export const directLayer = (
65
+ config: PgClient.PgClientConfig,
66
+ ): Layer.Layer<PgClientDirect, SqlError.SqlError> =>
67
+ Layer.scoped(PgClientDirect, PgClient.make(config)).pipe(
68
+ Layer.provide(Reactivity.layer),
69
+ )