@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,219 @@
1
+ /**
2
+ * Single source of truth for the tag-first match SQL.
3
+ *
4
+ * The servable-query matching semantics — the tagged AND-superset match with
5
+ * distinct-tag counting, the allowed-types filter, the exclusive `after` bound —
6
+ * were hand-duplicated between the TS read path (`store.ts`) and the plpgsql
7
+ * conflict check (`ddl.ts`): two SQL dialect-copies, in two files, that drift
8
+ * independently (the read's wildcard mis-routing of a degenerate empty item was
9
+ * one such drift). This module emits the shared fragments ONCE, parameterised by:
10
+ *
11
+ * - the PLACEHOLDER TOKENS each call site binds — plpgsql parameter names
12
+ * (`query_items`, `COALESCE(after_id, 0)`) for the conflict body vs bound
13
+ * `$n` placeholders for the runtime `sql.unsafe(text, params)` read; and
14
+ * - the PROJECTION — full rows (read) vs an `EXISTS`/`LIMIT 1` probe (conflict),
15
+ * which each call site wraps around the shared `qualifiedRows` source.
16
+ *
17
+ * The `jsonb_to_recordset` event column list also lives here as ONE descriptor,
18
+ * so the write recordset (`ddl.ts`) and the read projection cannot disagree.
19
+ *
20
+ * Table names arrive PRE-QUOTED (via `quoteQualified` in `ddl.ts`), so this
21
+ * module never quotes identifiers itself and stays free of the naming logic.
22
+ * Every fragment assumes the main events table is aliased `m`.
23
+ */
24
+
25
+ /**
26
+ * The event payload columns shared by the write recordset and every read
27
+ * projection, as `[name, recordsetType]` pairs. `id` (the `bigserial` position)
28
+ * is prepended by the read projection and absent from the events recordset.
29
+ *
30
+ * `as const` freezes it into a tuple of literal names so `EventColumnName` (below)
31
+ * can derive the exact column-name union — the whole point of the "one descriptor"
32
+ * story: the TS transport shapes in `internal/transport.ts` are mapped types over
33
+ * that union, so a column rename here is a COMPILE error there, not a silent drift
34
+ * the lax read boundary (ADR-0006) would only surface as corrupt data under test.
35
+ */
36
+ const EVENT_COLUMNS = [
37
+ ['type', 'text'],
38
+ ['data', 'text'],
39
+ ['tags', 'text[]'],
40
+ ['uuid', 'text'],
41
+ ['occurred_at', 'timestamptz'],
42
+ ] as const satisfies ReadonlyArray<
43
+ readonly [name: string, recordsetType: string]
44
+ >
45
+
46
+ /**
47
+ * The event payload column NAMES as a string-literal union, derived from the ONE
48
+ * `EVENT_COLUMNS` descriptor. `internal/transport.ts` builds its transport shapes
49
+ * (`EventJson`, the JSON sent to the append functions; `EventRow`, a raw driver
50
+ * row) as mapped types over this union, so the transport shapes cannot drift from
51
+ * the SQL recordset/projection — a rename in the descriptor removes the old key
52
+ * from the union and makes the mapped type fail to compile.
53
+ */
54
+ export type EventColumnName = (typeof EVENT_COLUMNS)[number][0]
55
+
56
+ /**
57
+ * The `jsonb_to_recordset(new_events) AS (…)` column definitions —
58
+ * `type text, data text, tags text[], uuid text, occurred_at timestamptz`. The
59
+ * transport `data` is base64 TEXT (restored to `bytea` by `decode(…, 'base64')`
60
+ * at insert), which is why it is `text` here, not `bytea`.
61
+ */
62
+ export const eventRecordsetColumns: string = EVENT_COLUMNS.map(
63
+ ([name, type]) => `${name} ${type}`,
64
+ ).join(', ')
65
+
66
+ /**
67
+ * The read projection column list (`id` + the event columns), optionally
68
+ * prefixed with a table alias (`'m.'`). Sourced from the one descriptor so it
69
+ * cannot drift from the recordset.
70
+ */
71
+ export const readColumns = (prefix = ''): string =>
72
+ [`${prefix}id`, ...EVENT_COLUMNS.map(([name]) => `${prefix}${name}`)].join(
73
+ ', ',
74
+ )
75
+
76
+ /**
77
+ * The allowed-types filter for a candidate row aliased `m`: an item with no
78
+ * types matches ANY type; otherwise the event's type must be one of them.
79
+ * `typesCol` is the `text[]` column holding the item's types — `q.allowed_types`
80
+ * for a tagged match, `qi.types` for a tagless one.
81
+ */
82
+ export const allowedTypesPredicate = (typesCol: string): string =>
83
+ `(array_length(${typesCol}, 1) IS NULL OR array_length(${typesCol}, 1) = 0 OR m.type = ANY(${typesCol}))`
84
+
85
+ /** The placeholder tokens + pre-quoted table names one call site binds. */
86
+ export interface MatchTokens {
87
+ /** SQL yielding the query-items jsonb: `query_items` or `$1::jsonb`. */
88
+ readonly queryItemsExpr: string
89
+ /** SQL for the exclusive lower bound: `COALESCE(after_id, 0)` or `$2`. */
90
+ readonly afterExpr: string
91
+ /** Pre-quoted main events table. */
92
+ readonly main: string
93
+ /** Pre-quoted tag junction table. */
94
+ readonly tag: string
95
+ }
96
+
97
+ /**
98
+ * The tag-first match CTE chain shared by the read and conflict paths: expand
99
+ * the query items (`qi`), join the tag junction, count DISTINCT matched tags per
100
+ * (event, item), and keep events matching an item's FULL tag superset. Returns
101
+ * `qi, initial_matches, matched_groups, qualified_ids` — the caller follows it
102
+ * with a projection CTE selecting from `qualified_ids`.
103
+ *
104
+ * ## Both sides of the count are SET SIZES, and that is the whole invariant
105
+ *
106
+ * `qualified_ids` compares two numbers, and the ONE thing an edit here must
107
+ * preserve is that they count the same kind of thing: `matched_tag_count` is the
108
+ * number of DISTINCT tags the event carried out of the item's requirement, so
109
+ * `required_tag_count` is the size of that requirement AS A SET and never the
110
+ * length of the list it arrived as. A query item may list one tag twice — no
111
+ * constructor de-duplicates, `assertServableQuery` admits it and
112
+ * `classifyServableQuery` routes it here — and the in-memory oracle folds an
113
+ * item's tags into a superset test that a repeat cannot change. Comparing a
114
+ * de-duplicated 1 against a list length of 2 refused such a match: the READ
115
+ * returned nothing and, because the plpgsql conflict body composes this same
116
+ * chain, the GUARD found no conflict and the append it should have refused
117
+ * succeeded — an optimistic-concurrency check that escaped silently.
118
+ *
119
+ * The asymmetry has two repairs — de-duplicate both sides, or expand both — and
120
+ * only the first is available here. The matched side CANNOT become a plain row
121
+ * count, because this engine's junction holds one row per (tag, event) OCCURRENCE
122
+ * and not per pair, so an event whose own `tags` list repeats a tag has two rows
123
+ * there and a row count would refuse a single-tag item against it. `ddl.ts` states
124
+ * that property on the junction, and it is what `COUNT(DISTINCT tag)` is here to
125
+ * absorb; the requirement is de-duplicated to meet it.
126
+ *
127
+ * `required_tag_count` is therefore computed in `qi`, once per ITEM, rather than
128
+ * in the group that consumes it — the chain groups per (event, item), so a
129
+ * subquery in `matched_groups` would be re-evaluated per matched event. It is
130
+ * `count(*)` over a `SELECT DISTINCT` rather than `count(DISTINCT …)` because the
131
+ * latter drops a NULL element: a `[tag, NULL]` requirement would shrink to 1,
132
+ * meet a single matched tag and match, where the oracle's superset test cannot be
133
+ * satisfied by a NULL and refuses it.
134
+ *
135
+ * The `array_length(qi.tags, 1) >= 1` guard excludes tagless items from the
136
+ * tagged branch (load-bearing for the conflict body, which also has a separate
137
+ * tagless branch; a no-op for the read, whose tagged shape is all-tagged). It
138
+ * reads the item's own array because it is a test of the item's SHAPE — tagged or
139
+ * tagless — and not part of the match arithmetic above.
140
+ */
141
+ export const taggedMatchChain = (tokens: MatchTokens): string =>
142
+ `qi AS (
143
+ SELECT types, tags, ordinality,
144
+ -- Both sides of the match compare DISTINCT-TAG counts: this is the
145
+ -- requirement's SET size, never the length of the list it arrived as.
146
+ (SELECT count(*) FROM (SELECT DISTINCT req.tag FROM unnest(tags) AS req(tag)) d)
147
+ AS required_tag_count
148
+ FROM ROWS FROM(
149
+ jsonb_to_recordset(${tokens.queryItemsExpr}) AS (types text[], tags text[])
150
+ ) WITH ORDINALITY
151
+ ),
152
+ initial_matches AS (
153
+ SELECT t.main_id, qi.ordinality, qi.required_tag_count,
154
+ qi.types AS allowed_types, t.tag
155
+ FROM qi
156
+ JOIN ${tokens.tag} t ON t.tag = ANY(qi.tags)
157
+ WHERE t.main_id > ${tokens.afterExpr}
158
+ AND array_length(qi.tags, 1) >= 1
159
+ ),
160
+ matched_groups AS (
161
+ SELECT main_id, ordinality, required_tag_count, allowed_types,
162
+ COUNT(DISTINCT tag) AS matched_tag_count
163
+ FROM initial_matches
164
+ GROUP BY main_id, ordinality, required_tag_count, allowed_types
165
+ ),
166
+ qualified_ids AS (
167
+ SELECT main_id, allowed_types
168
+ FROM matched_groups
169
+ WHERE matched_tag_count = required_tag_count
170
+ )`
171
+
172
+ /**
173
+ * The shared qualified-rows source: join `qualified_ids` back to the main table
174
+ * (aliased `m`), re-apply the exclusive `after` bound, and filter by allowed
175
+ * types. The read wraps this in an ordered/limited `DISTINCT m.id` CTE; the
176
+ * conflict wraps it in a `SELECT 1 … LIMIT 1` existence probe.
177
+ */
178
+ export const qualifiedRows = (
179
+ tokens: Pick<MatchTokens, 'main' | 'afterExpr'>,
180
+ ): string =>
181
+ `FROM qualified_ids q
182
+ JOIN ${tokens.main} m ON m.id = q.main_id
183
+ WHERE m.id > ${tokens.afterExpr}
184
+ AND ${allowedTypesPredicate('q.allowed_types')}`
185
+
186
+ /** The wildcard read (no items): every event after the bound, ordered/limited. */
187
+ export const wildcardReadSql = (main: string): string =>
188
+ `SELECT ${readColumns()}
189
+ FROM ${main}
190
+ WHERE id > $1
191
+ ORDER BY id ASC
192
+ LIMIT $2`
193
+
194
+ /** The type-only read (single tagless item): filter by `type = ANY($2)`. */
195
+ export const typeOnlyReadSql = (main: string): string =>
196
+ `SELECT ${readColumns()}
197
+ FROM ${main}
198
+ WHERE id > $1 AND type = ANY($2)
199
+ ORDER BY id ASC
200
+ LIMIT $3`
201
+
202
+ /**
203
+ * The tagged read: the shared match chain, then a `filtered_ids` CTE that orders
204
+ * and limits the qualified ids by position, then a full-row select. Bound params
205
+ * are `$1` = query-items jsonb, `$2` = exclusive `after`, `$3` = limit (`NULL` =
206
+ * no limit).
207
+ */
208
+ export const taggedReadSql = (main: string, tag: string): string =>
209
+ `WITH ${taggedMatchChain({ queryItemsExpr: '$1::jsonb', afterExpr: '$2', main, tag })},
210
+ filtered_ids AS (
211
+ SELECT DISTINCT m.id
212
+ ${qualifiedRows({ main, afterExpr: '$2' })}
213
+ ORDER BY m.id ASC
214
+ LIMIT $3
215
+ )
216
+ SELECT ${readColumns('m.')}
217
+ FROM ${main} m
218
+ WHERE m.id IN (SELECT id FROM filtered_ids)
219
+ ORDER BY m.id ASC`
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The read-plan compiler for the Postgres engine, extracted from `store.ts` so it
3
+ * is a self-contained unit depending only on the PRE-QUOTED table names — never on
4
+ * the SQL clients. `store.ts`'s `read` and its `subscribe` — the latter feeding the
5
+ * plan to core's subscribe machine as that machine's `fetch` seam — both compile a
6
+ * plan here and run it against whichever client they hold.
7
+ *
8
+ * `compileFetchPlan` turns a servable `Query` into prepared SQL text plus a
9
+ * cursor-only `bind`, so a long-lived subscription re-binds only the cursor rather
10
+ * than re-dispatching the shape and re-serialising the query JSON on every poll.
11
+ * Which shape a query IS is not decided here: core's `classifyServableQuery` owns
12
+ * that dispatch beside the grammar it dispatches over (`Query.ts`), including the
13
+ * degenerate empty item's routing. The JSON/JSONB transport shapes and
14
+ * (de)serialisers — `EventRow`, `jsonbText`, `toQueryItemsJson`, `rowToSequenced`
15
+ * — live in `internal/transport.ts`, and this module only compiles plans.
16
+ *
17
+ * A plan compiled here is therefore the one place the Postgres engine dispatches
18
+ * a shape in TypeScript, and the exhaustiveness below protects BOTH verbs that
19
+ * compile one — `read`, and the `subscribe` poll sharing its plan. What it cannot
20
+ * reach is the append: its conflict guard covers the same three shapes inside its
21
+ * plpgsql body, where no compiler follows, and `ddl.ts` owns what that rests on
22
+ * instead.
23
+ */
24
+ import type { SqlClient, SqlError } from '@effect/sql'
25
+ import type { Position, ReadLimit } from '@kairos-es/core'
26
+ import { classifyServableQuery, ORIGIN, type Query } from '@kairos-es/core'
27
+ import type { Effect } from 'effect'
28
+ import { taggedReadSql, typeOnlyReadSql, wildcardReadSql } from './matchSql'
29
+ import { type EventRow, jsonbText, toQueryItemsJson } from './transport'
30
+
31
+ /** The pre-quoted table names a plan reads from (see `quoteQualified`). */
32
+ export interface ReadTables {
33
+ /** Pre-quoted main events table. */
34
+ readonly main: string
35
+ /** Pre-quoted tag junction table. */
36
+ readonly tag: string
37
+ }
38
+
39
+ /**
40
+ * A COMPILED read plan: the prepared SQL text plus a `bind` that produces the
41
+ * positional params for a given `after`/`limit`. Everything
42
+ * query-dependent-but-cursor-independent — the shape dispatch, the SQL text, the
43
+ * query-items JSON serialisation — is done ONCE, so a long-lived subscription's
44
+ * poll loop re-binds only the cursor rather than re-classifying and re-JSON-
45
+ * serialising on every iteration.
46
+ */
47
+ export interface FetchPlan {
48
+ readonly text: string
49
+ readonly bind: (
50
+ after: Position,
51
+ limit: ReadLimit | null,
52
+ ) => ReadonlyArray<unknown>
53
+ }
54
+
55
+ /**
56
+ * Compile a servable query into a `FetchPlan`. The SQL comes from the shared
57
+ * `matchSql` builders (the tagged path composes the same emitted match chain as the
58
+ * conflict check, which binds its own placeholder tokens and wraps it differently),
59
+ * with the pre-quoted names embedded and the VALUES bound as `$n` parameters
60
+ * (never interpolated).
61
+ *
62
+ * The `switch` is exhaustive over core's `ServableQueryShape` with NO `default`
63
+ * and no trailing fall-through arm, and what that buys is a COMPILE error where
64
+ * there would otherwise be a silent mis-serve. Core owns that union and this
65
+ * module only consumes it, so a fourth member added there is a change this file
66
+ * cannot see coming: a cascade ending in an unguarded tagged branch would have
67
+ * SERVED that shape as tagged, and both `read` and the `subscribe` poll sharing
68
+ * its plan would have returned the wrong rows — no error raised, nothing failing
69
+ * to typecheck. Every arm returns instead, so the added member leaves this
70
+ * function with a reachable end and a return type that does not admit
71
+ * `undefined`, and the module stops compiling.
72
+ */
73
+ export const compileFetchPlan = (
74
+ tables: ReadTables,
75
+ query: Query,
76
+ ): FetchPlan => {
77
+ const shape = classifyServableQuery(query)
78
+ switch (shape.kind) {
79
+ case 'wildcard':
80
+ return {
81
+ text: wildcardReadSql(tables.main),
82
+ bind: (after, limit) => [after, limit],
83
+ }
84
+ case 'typeOnly': {
85
+ // node-postgres binds a JS array param as a Postgres array literal, so
86
+ // `type = ANY($2)` matches any of the item's types. The item's own list is
87
+ // bound as it stands: a branded `EventType` IS its string to the driver, so
88
+ // nothing here has to unwrap or copy it.
89
+ const types = shape.types
90
+ return {
91
+ text: typeOnlyReadSql(tables.main),
92
+ bind: (after, limit) => [after, types, limit],
93
+ }
94
+ }
95
+ case 'tagged': {
96
+ // `after` ($2) is applied at BOTH CTE stages; `limit` ($3) inside the
97
+ // ordered filtered_ids so it caps by position, not on the outer join.
98
+ const itemsJson = jsonbText(toQueryItemsJson(query))
99
+ return {
100
+ text: taggedReadSql(tables.main, tables.tag),
101
+ bind: (after, limit) => [itemsJson, after, limit],
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Run a compiled plan for a given `after`/`limit`, honouring `after` (exclusive;
109
+ * absent → `0`) and `limit` (`NULL` = no limit), ordered by `id ASC`.
110
+ */
111
+ export const runFetch = (
112
+ sql: SqlClient.SqlClient,
113
+ plan: FetchPlan,
114
+ after: Position | undefined,
115
+ limit: ReadLimit | undefined,
116
+ ): Effect.Effect<ReadonlyArray<EventRow>, SqlError.SqlError> =>
117
+ sql.unsafe<EventRow>(plan.text, plan.bind(after ?? ORIGIN, limit ?? null))
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The JSON/JSONB transport for the Postgres engine: the wire shapes and the
3
+ * (de)serialisers that carry an event batch / query items ACROSS the boundary into
4
+ * the append functions, and map a raw driver row BACK to a core `SequencedEvent`.
5
+ * Extracted from `store.ts` (the write side) and `internal/readPlan.ts` (the read
6
+ * side) so the transport concept lives in ONE module rather than being split
7
+ * across the write path and the read-plan compiler — the module name now predicts
8
+ * where transport code lives, and the two per-column type maps (`EventJson`,
9
+ * `EventRow`) sit next to each other rather than in two files.
10
+ *
11
+ * The 5-column event contract stays single-sourced on `EventColumnName`
12
+ * (`internal/matchSql.ts`), the one column-NAME descriptor: both type maps here
13
+ * are mapped types over it, so a column rename in `EVENT_COLUMNS` is a COMPILE
14
+ * error in this module, never a silent transport drift the lax read boundary
15
+ * (ADR-0006) would only surface as corrupt data under test.
16
+ *
17
+ * The read boundary (`rowToSequenced`) is the trusted, deliberately-lax
18
+ * deserialisation point (ADR-0006 store-laxness); the write boundary
19
+ * (`toEventJson`) serialises `data` as base64 text and domain time as an ISO
20
+ * string, both restored in SQL.
21
+ */
22
+ import type { DcbEvent, Position, Query, SequencedEvent } from '@kairos-es/core'
23
+ import { DateTime, Encoding } from 'effect'
24
+ import type { EventColumnName } from './matchSql'
25
+
26
+ /**
27
+ * Serialise a value to a JSON STRING for transport as a `::jsonb`-cast bound
28
+ * parameter.
29
+ *
30
+ * WHY not `sql.json(value)`: `@effect/sql-pg`'s `json` helper binds the RAW
31
+ * value, and node-postgres serialises a JS *array* bound param as a Postgres
32
+ * ARRAY literal (`{…}`), not JSON — so `sql.json([...])::jsonb` fails with a
33
+ * `22P02` invalid-JSON error (an array literal is not valid JSON). Passing a
34
+ * pre-stringified JSON string and casting `::jsonb` sends plain text that
35
+ * Postgres parses as JSON, which round-trips reliably for both objects and
36
+ * arrays. Kept as a named helper so every JSON-transport call site is consistent
37
+ * and the reasoning lives in one place.
38
+ */
39
+ export const jsonbText = (value: unknown): string => JSON.stringify(value)
40
+
41
+ /** The JSON shape of one query item: types OR-filter, tags AND-superset. */
42
+ export interface QueryItemJson {
43
+ readonly types: ReadonlyArray<string>
44
+ readonly tags: ReadonlyArray<string>
45
+ }
46
+
47
+ /** Serialise a `Query`'s items into their transport JSON shape. */
48
+ export const toQueryItemsJson = (query: Query): ReadonlyArray<QueryItemJson> =>
49
+ query.items.map((item) => ({
50
+ types: [...item.types],
51
+ tags: [...item.tags],
52
+ }))
53
+
54
+ /**
55
+ * The per-column TS types of the transport JSON shape — one entry per
56
+ * `EVENT_COLUMNS` name. `data` is base64 TEXT and `occurred_at` an ISO string
57
+ * (both restored in SQL: `decode(…, 'base64')` / `timestamptz`); the rest map
58
+ * straight across. Kept as a plain map so `EventJson` below is a mapped type over
59
+ * the ONE column descriptor rather than a hand-restated column list.
60
+ */
61
+ interface EventJsonColumns {
62
+ readonly type: string
63
+ readonly data: string
64
+ readonly tags: ReadonlyArray<string>
65
+ readonly uuid: string
66
+ readonly occurred_at: string
67
+ }
68
+
69
+ /**
70
+ * The JSON shape of one event as sent to the append functions, as a mapped type
71
+ * over the shared `EventColumnName` descriptor (`internal/matchSql.ts`). `data`
72
+ * is base64 text (restored to `bytea` by `decode(…, 'base64')` in plpgsql);
73
+ * `occurred_at` is an ISO string parsed by `timestamptz`. Because the keys come
74
+ * from the same descriptor that builds the `jsonb_to_recordset(new_events)`
75
+ * column list, the two cannot drift — rename a column in `EVENT_COLUMNS` and this
76
+ * mapped type stops compiling (the old key is gone from `EventJsonColumns`).
77
+ */
78
+ export type EventJson = { readonly [K in EventColumnName]: EventJsonColumns[K] }
79
+
80
+ /** Serialise one core `DcbEvent` into its transport JSON shape. */
81
+ export const toEventJson = (event: DcbEvent): EventJson => ({
82
+ type: event.type,
83
+ // `Encoding.encodeBase64` (from `effect`) keeps this dependency-free and pure
84
+ // — no `Buffer`, so no `@types/node` and no browser/runtime coupling.
85
+ data: Encoding.encodeBase64(event.data),
86
+ tags: [...event.tags],
87
+ uuid: event.uuid,
88
+ occurred_at: DateTime.formatIso(event.occurredAt),
89
+ })
90
+
91
+ /**
92
+ * The per-column TS types of a raw driver row — one entry per `EVENT_COLUMNS`
93
+ * name. These are node-postgres's decoded JS types: `data` a `Buffer` (or null
94
+ * for a payload-less event), `occurred_at` a JS `Date`, `tags` a `string[]`. `id`
95
+ * (the `bigserial` position) is NOT here — it is prepended by the read projection
96
+ * and absent from the events recordset, so it sits outside the shared descriptor.
97
+ */
98
+ interface EventRowColumns {
99
+ readonly type: string
100
+ readonly data: Uint8Array | null
101
+ readonly tags: ReadonlyArray<string>
102
+ readonly uuid: string
103
+ readonly occurred_at: Date
104
+ }
105
+
106
+ /**
107
+ * A raw row from the main table as node-postgres returns it, as `id` plus a
108
+ * mapped type over the shared `EventColumnName` descriptor. `id` is `int8`, which
109
+ * the driver hands back as a decimal STRING (JS `number` is unsafe past 2^53).
110
+ * The payload columns cannot drift from the read projection: a rename in
111
+ * `EVENT_COLUMNS` breaks this mapped type.
112
+ */
113
+ export type EventRow = { readonly id: string } & {
114
+ readonly [K in EventColumnName]: EventRowColumns[K]
115
+ }
116
+
117
+ /**
118
+ * Map a raw main-table row to a core `SequencedEvent`. `data` NULL → empty bytes
119
+ * (a payload-less event round-trips to a zero-length `Uint8Array`); `occurred_at`
120
+ * Date → `DateTime.Utc`; `id` string → branded `Position`.
121
+ *
122
+ * The branded scalars (`type`, `tags`, `uuid`, `position`) are minted by trusted
123
+ * `as`-cast, NOT by re-running `EventType.make` / `Tag.make` etc. This is
124
+ * deliberate: these bytes were validated at write time, and the store schema is
125
+ * intentionally LAX on read (ADR-0006 store-laxness — hand-assembled or pre-ADR
126
+ * events must remain readable), so re-validating here could REJECT a legitimately
127
+ * stored value and break that contract. It mirrors the in-memory oracle, which
128
+ * likewise mints branded positions by cast at its trusted allocation boundary
129
+ * (`inMemory.ts`). The read boundary is the analogous trusted deserialisation
130
+ * point.
131
+ */
132
+ export const rowToSequenced = (row: EventRow): SequencedEvent => ({
133
+ event: {
134
+ type: row.type as DcbEvent['type'],
135
+ data: row.data ?? new Uint8Array(0),
136
+ tags: row.tags as unknown as DcbEvent['tags'],
137
+ uuid: row.uuid as DcbEvent['uuid'],
138
+ occurredAt: DateTime.unsafeFromDate(row.occurred_at),
139
+ },
140
+ position: BigInt(row.id) as Position,
141
+ })