@kairos-es/store-postgres 1.0.0-alpha.3 → 1.0.0-alpha.5

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 (91) hide show
  1. package/README.md +61 -29
  2. package/dist/cjs/clients.js +34 -24
  3. package/dist/cjs/clients.js.map +1 -1
  4. package/dist/cjs/config.js +9 -15
  5. package/dist/cjs/config.js.map +1 -1
  6. package/dist/cjs/ensureSchema.js +8 -3
  7. package/dist/cjs/ensureSchema.js.map +1 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/internal/ddl.js +29 -12
  10. package/dist/cjs/internal/ddl.js.map +1 -1
  11. package/dist/cjs/internal/matchSql.js +57 -4
  12. package/dist/cjs/internal/matchSql.js.map +1 -1
  13. package/dist/cjs/internal/readPlan.js +33 -5
  14. package/dist/cjs/internal/readPlan.js.map +1 -1
  15. package/dist/cjs/internal/transport.js +42 -15
  16. package/dist/cjs/internal/transport.js.map +1 -1
  17. package/dist/cjs/store.js +30 -25
  18. package/dist/cjs/store.js.map +1 -1
  19. package/dist/dts-cjs/clients.d.ts +66 -0
  20. package/dist/dts-cjs/clients.d.ts.map +1 -0
  21. package/dist/{dts → dts-cjs}/config.d.ts +9 -19
  22. package/dist/{dts → dts-cjs}/config.d.ts.map +1 -1
  23. package/dist/{dts → dts-cjs}/ensureSchema.d.ts +8 -3
  24. package/dist/dts-cjs/ensureSchema.d.ts.map +1 -0
  25. package/dist/{dts → dts-cjs}/index.d.ts +4 -3
  26. package/dist/dts-cjs/index.d.ts.map +1 -0
  27. package/dist/{dts → dts-cjs}/internal/ddl.d.ts +1 -1
  28. package/dist/{dts → dts-cjs}/internal/ddl.d.ts.map +1 -1
  29. package/dist/{dts → dts-cjs}/internal/matchSql.d.ts +26 -2
  30. package/dist/dts-cjs/internal/matchSql.d.ts.map +1 -0
  31. package/dist/{dts → dts-cjs}/internal/readPlan.d.ts +1 -1
  32. package/dist/{dts → dts-cjs}/internal/readPlan.d.ts.map +1 -1
  33. package/dist/{dts → dts-cjs}/internal/transport.d.ts +34 -19
  34. package/dist/dts-cjs/internal/transport.d.ts.map +1 -0
  35. package/dist/dts-cjs/package.json +3 -0
  36. package/dist/dts-cjs/store.d.ts.map +1 -0
  37. package/dist/dts-esm/clients.d.ts +66 -0
  38. package/dist/dts-esm/clients.d.ts.map +1 -0
  39. package/dist/dts-esm/config.d.ts +48 -0
  40. package/dist/dts-esm/config.d.ts.map +1 -0
  41. package/dist/dts-esm/ensureSchema.d.ts +40 -0
  42. package/dist/dts-esm/ensureSchema.d.ts.map +1 -0
  43. package/dist/dts-esm/index.d.ts +42 -0
  44. package/dist/dts-esm/index.d.ts.map +1 -0
  45. package/dist/dts-esm/internal/ddl.d.ts +174 -0
  46. package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
  47. package/dist/dts-esm/internal/matchSql.d.ts +164 -0
  48. package/dist/dts-esm/internal/matchSql.d.ts.map +1 -0
  49. package/dist/dts-esm/internal/readPlan.d.ts +72 -0
  50. package/dist/dts-esm/internal/readPlan.d.ts.map +1 -0
  51. package/dist/dts-esm/internal/transport.d.ts +131 -0
  52. package/dist/dts-esm/internal/transport.d.ts.map +1 -0
  53. package/dist/dts-esm/package.json +3 -0
  54. package/dist/dts-esm/store.d.ts +17 -0
  55. package/dist/dts-esm/store.d.ts.map +1 -0
  56. package/dist/esm/clients.js +34 -24
  57. package/dist/esm/clients.js.map +1 -1
  58. package/dist/esm/config.js +9 -15
  59. package/dist/esm/config.js.map +1 -1
  60. package/dist/esm/ensureSchema.js +8 -3
  61. package/dist/esm/ensureSchema.js.map +1 -1
  62. package/dist/esm/index.js +4 -3
  63. package/dist/esm/index.js.map +1 -1
  64. package/dist/esm/internal/ddl.js +30 -13
  65. package/dist/esm/internal/ddl.js.map +1 -1
  66. package/dist/esm/internal/matchSql.js +55 -2
  67. package/dist/esm/internal/matchSql.js.map +1 -1
  68. package/dist/esm/internal/readPlan.js +32 -5
  69. package/dist/esm/internal/readPlan.js.map +1 -1
  70. package/dist/esm/internal/transport.js +42 -15
  71. package/dist/esm/internal/transport.js.map +1 -1
  72. package/dist/esm/store.js +30 -25
  73. package/dist/esm/store.js.map +1 -1
  74. package/package.json +13 -11
  75. package/src/clients.ts +43 -31
  76. package/src/config.ts +12 -13
  77. package/src/ensureSchema.ts +8 -3
  78. package/src/index.ts +4 -3
  79. package/src/internal/ddl.ts +33 -13
  80. package/src/internal/matchSql.ts +63 -1
  81. package/src/internal/readPlan.ts +11 -6
  82. package/src/internal/transport.ts +71 -24
  83. package/src/store.ts +35 -28
  84. package/dist/dts/clients.d.ts +0 -34
  85. package/dist/dts/clients.d.ts.map +0 -1
  86. package/dist/dts/ensureSchema.d.ts.map +0 -1
  87. package/dist/dts/index.d.ts.map +0 -1
  88. package/dist/dts/internal/matchSql.d.ts.map +0 -1
  89. package/dist/dts/internal/transport.d.ts.map +0 -1
  90. package/dist/dts/store.d.ts.map +0 -1
  91. /package/dist/{dts → dts-cjs}/store.d.ts +0 -0
package/src/clients.ts CHANGED
@@ -1,69 +1,81 @@
1
1
  /**
2
2
  * The TWO store-owned Postgres clients — the "Neon trap" mitigation (ADR-0002).
3
3
  *
4
- * `@effect/sql-pg` exposes a single `PgClient` (and `SqlClient`) tag; a
4
+ * `@effect/sql-pg` exposes a single `PgClient` (and `SqlClient`) service; a
5
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:
6
+ * collide on that one key — the second would shadow the first. So instead we
7
+ * define two DISTINCT `Context.Service`s, each HOLDING a `PgClient` value:
8
8
  *
9
9
  * - `PgClientPooled` — the pooled client used for reads, the append function,
10
- * and `notify`. Backed by a `pg.Pool`; connections are recycled per query.
10
+ * and `notify`. Its statements borrow a connection from the client's pool per
11
+ * query.
11
12
  * - `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
13
+ * built-in `listen` RESERVES a connection from that client's own pool and
14
+ * holds it for the life of the subscription, so the listening session lives
15
+ * on whatever host the client was configured with. On a Neon POOLED endpoint
16
+ * (`…-pooler…`) that session is a PgBouncer front-door connection, which
17
+ * SILENTLY DROPS `LISTEN`/`NOTIFY` — the subscription would simply never
16
18
  * wake. Pointing `PgClientDirect` at Neon's DIRECT (non-pooler) endpoint fixes
17
19
  * it. `subscribe` is the ONLY method that touches `PgClientDirect`.
18
20
  *
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
+ * Because each live subscription holds one of `PgClientDirect`'s pooled
22
+ * connections until it closes, that client's `maxConnections` is also the number
23
+ * of subscriptions that can be listening at once. A subscription past it waits
24
+ * for a connection on its wake fibre only — delivery still proceeds at the poll
25
+ * rate meanwhile (see `store.ts`) — so size the direct client for the expected
26
+ * subscriber count rather than for query throughput.
27
+ *
28
+ * For a plain (non-Neon) Postgres both services point at the same URL; the split
29
+ * costs nothing there. The two-service design is what makes the Neon fix a
21
30
  * deployment-time wiring choice rather than a code change.
22
31
  */
23
- import { Reactivity } from '@effect/experimental'
24
- import type { SqlError } from '@effect/sql'
25
32
  import { PgClient } from '@effect/sql-pg'
26
33
  import { Context, Layer } from 'effect'
34
+ import { Reactivity } from 'effect/reactivity'
35
+ import type { SqlError } from 'effect/sql'
27
36
 
28
37
  /**
29
- * The pooled client tag: reads, the append function, and `notify`. Holds a
38
+ * The pooled client service: reads, the append function, and `notify`. Holds a
30
39
  * `PgClient` value rather than being a `PgClient.layer`, so it never collides
31
- * with the direct client on the shared `PgClient`/`SqlClient` tag.
40
+ * with the direct client on the shared `PgClient`/`SqlClient` key.
32
41
  */
33
- export class PgClientPooled extends Context.Tag(
34
- '@kairos-es/store-postgres/PgClientPooled',
35
- )<PgClientPooled, PgClient.PgClient>() {}
42
+ export class PgClientPooled extends Context.Service<
43
+ PgClientPooled,
44
+ PgClient.PgClient
45
+ >()('@kairos-es/store-postgres/PgClientPooled') {}
36
46
 
37
47
  /**
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.
48
+ * The direct (non-pooler) client service: the `subscribe` listener ONLY. Point
49
+ * this at a direct endpoint on Neon so `LISTEN`/`NOTIFY` is not dropped by the
50
+ * pooler.
40
51
  */
41
- export class PgClientDirect extends Context.Tag(
42
- '@kairos-es/store-postgres/PgClientDirect',
43
- )<PgClientDirect, PgClient.PgClient>() {}
52
+ export class PgClientDirect extends Context.Service<
53
+ PgClientDirect,
54
+ PgClient.PgClient
55
+ >()('@kairos-es/store-postgres/PgClientDirect') {}
44
56
 
45
57
  /**
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
58
+ * Build a `PgClientPooled` layer from a `PgClient` pool config. We construct the
59
+ * client via `PgClient.make` (scoped) and place it in our OWN service, then
60
+ * provide `Reactivity.layer` (which `PgClient.make` requires) — exactly what the
49
61
  * built-in `PgClient.layer` does internally, minus the collision-prone claim on
50
- * the shared `PgClient` tag.
62
+ * the shared `PgClient` key.
51
63
  */
52
64
  export const pooledLayer = (
53
- config: PgClient.PgClientConfig,
65
+ config: PgClient.PgPoolConfig,
54
66
  ): Layer.Layer<PgClientPooled, SqlError.SqlError> =>
55
- Layer.scoped(PgClientPooled, PgClient.make(config)).pipe(
67
+ Layer.effect(PgClientPooled, PgClient.make(config)).pipe(
56
68
  Layer.provide(Reactivity.layer),
57
69
  )
58
70
 
59
71
  /**
60
- * Build a `PgClientDirect` layer from a `PgClient` config. Same construction as
72
+ * Build a `PgClientDirect` layer from a `PgClient` pool config. Same construction as
61
73
  * `pooledLayer`; the two differ only in which endpoint the caller points them
62
74
  * at (and that only matters on Neon).
63
75
  */
64
76
  export const directLayer = (
65
- config: PgClient.PgClientConfig,
77
+ config: PgClient.PgPoolConfig,
66
78
  ): Layer.Layer<PgClientDirect, SqlError.SqlError> =>
67
- Layer.scoped(PgClientDirect, PgClient.make(config)).pipe(
79
+ Layer.effect(PgClientDirect, PgClient.make(config)).pipe(
68
80
  Layer.provide(Reactivity.layer),
69
81
  )
package/src/config.ts CHANGED
@@ -28,9 +28,9 @@ import { oversizedIdentifier } from './internal/ddl'
28
28
  * `sql(identifier)` dot-splits, so an embedded dot would silently re-partition
29
29
  * the name into the wrong schema/object.
30
30
  */
31
- const IdentifierAtom = Schema.String.pipe(
32
- Schema.nonEmptyString(),
33
- Schema.pattern(/^[^.]+$/, {
31
+ const IdentifierAtom = Schema.String.check(
32
+ Schema.isNonEmpty(),
33
+ Schema.isPattern(/^[^.]+$/, {
34
34
  identifier: 'PostgresIdentifierAtom',
35
35
  description: 'a non-empty identifier atom containing no "."',
36
36
  }),
@@ -63,29 +63,28 @@ const IdentifierAtom = Schema.String.pipe(
63
63
  * fetches before looping.
64
64
  */
65
65
  export const PostgresStoreConfig = Schema.Struct({
66
- // `exact: true` keeps the decoded type at `key?: T` (never `T | undefined`),
66
+ // `optionalKey` keeps the decoded type at `key?: T` (never `T | undefined`),
67
67
  // matching the repo's `exactOptionalPropertyTypes` discipline — an absent key,
68
68
  // not an explicit `undefined`.
69
- schema: Schema.optionalWith(IdentifierAtom, { exact: true }),
70
- tablePrefix: Schema.optionalWith(IdentifierAtom, { exact: true }),
71
- lockTimeout: Schema.optionalWith(
72
- Schema.Number.pipe(Schema.nonNegative(), Schema.finite()),
73
- { exact: true },
69
+ schema: Schema.optionalKey(IdentifierAtom),
70
+ tablePrefix: Schema.optionalKey(IdentifierAtom),
71
+ lockTimeout: Schema.optionalKey(
72
+ Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isFinite()),
74
73
  ),
75
74
  // The two subscribe knobs are core's field definitions, not this engine's: see
76
75
  // `subscriptionTuningFields`.
77
76
  ...subscriptionTuningFields,
78
- }).pipe(
77
+ }).check(
79
78
  // Cross-field guard: the derived table/index/function/channel names must all
80
79
  // fit Postgres's 63-byte identifier limit. Returning the message string fails
81
- // the decode with it; `undefined`/`true` passes.
82
- Schema.filter((config) => oversizedIdentifier(config) ?? true),
80
+ // the decode with it; `undefined` passes.
81
+ Schema.makeFilter((config) => oversizedIdentifier(config)),
83
82
  )
84
83
  export type PostgresStoreConfig = typeof PostgresStoreConfig.Type
85
84
 
86
85
  /**
87
86
  * Decode an unknown value into a validated `PostgresStoreConfig`, throwing a
88
- * `ParseError` on any violation. Used by `layer`/`ensureSchema` so a bad config
87
+ * `SchemaError` on any violation. Used by `layer`/`ensureSchema` so a bad config
89
88
  * fails at construction time.
90
89
  */
91
90
  export const decodePostgresStoreConfig =
@@ -4,7 +4,7 @@
4
4
  * This is deliberately NOT a migrator. It is a single `Effect<void, SqlError,
5
5
  * SqlClient>` that creates (or refreshes) every object a store owns: both
6
6
  * tables, both indexes, and both plpgsql functions. It requires the GENERIC
7
- * `SqlClient` tag — NOT `PgClientPooled` — precisely so a consumer can drop it
7
+ * `SqlClient` service — NOT `PgClientPooled` — precisely so a consumer can drop it
8
8
  * straight into THEIR OWN migrator, e.g.:
9
9
  *
10
10
  * ```ts
@@ -18,13 +18,18 @@
18
18
  * consumer, this package only provides the idempotent PAYLOAD.
19
19
  *
20
20
  * Idempotence: every statement is `CREATE … IF NOT EXISTS` or `CREATE OR REPLACE
21
- * FUNCTION`, so re-running against an existing store is a no-op. Names come from
21
+ * FUNCTION`, so re-running it is safe. It is NOT a no-op across versions: a
22
+ * re-run replaces the append functions with this version's, and does NOT alter
23
+ * an existing table, so a column a newer version adds must be added by hand
24
+ * before the re-run (the release notes give the statement). A store whose
25
+ * table gained a column but whose functions were never replaced keeps writing
26
+ * through the old ones, which do not know the column. Names come from
22
27
  * `sql(identifier)` (via the pre-quoted builder in `internal/ddl.ts`); the DDL
23
28
  * bodies carry no bound values (pure schema), and the append functions keep
24
29
  * their runtime VALUES as bound parameters, never interpolated.
25
30
  */
26
- import { SqlClient, type SqlError } from '@effect/sql'
27
31
  import { Effect } from 'effect'
32
+ import { SqlClient, type SqlError } from 'effect/sql'
28
33
  import { decodePostgresStoreConfig, type PostgresStoreConfig } from './config'
29
34
  import { resolveNames, runDdl } from './internal/ddl'
30
35
 
package/src/index.ts CHANGED
@@ -30,9 +30,10 @@
30
30
  * const store = layer(config).pipe(Layer.provide(clients))
31
31
  * ```
32
32
  *
33
- * The store `layer` performs NO DDL: it depends only on the two client tags and
34
- * assumes `ensureSchema` has run. `ensureSchema` requires the generic `SqlClient`
35
- * tag so it slots into any migrator; the runtime uses the store-owned tags.
33
+ * The store `layer` performs NO DDL: it depends only on the two client services
34
+ * and assumes `ensureSchema` has run. `ensureSchema` requires the generic
35
+ * `SqlClient` service so it slots into any migrator; the runtime uses the
36
+ * store-owned services.
36
37
  */
37
38
  export {
38
39
  directLayer,
@@ -25,11 +25,14 @@
25
25
  * would emit; VALUES stay bound parameters (`sql.unsafe` with `params`) and are
26
26
  * never interpolated.
27
27
  */
28
- import type { SqlClient, Statement } from '@effect/sql'
28
+ import type { SqlClient, Statement } from 'effect/sql'
29
29
  import {
30
30
  allowedTypesPredicate,
31
+ eventColumnList,
32
+ eventInsertSelectList,
31
33
  eventRecordsetColumns,
32
34
  qualifiedRows,
35
+ RECORDSET_ALIAS,
33
36
  taggedMatchChain,
34
37
  } from './matchSql'
35
38
 
@@ -250,6 +253,16 @@ export interface AppendBodyOptions {
250
253
  * preserves batch order, and `RETURNING id, tags` is captured into a temp
251
254
  * result set so the tag fan-out and the head can both read it. `data` arrives as
252
255
  * a base64 text field and is `decode(…, 'base64')`-restored to `bytea`.
256
+ *
257
+ * The recordset definition, its projection, the `INSERT INTO (…)` target list and
258
+ * the `SELECT` list are all derived from the one `EVENT_COLUMNS` descriptor in
259
+ * `matchSql.ts`, never written out here. The last two must agree POSITIONALLY and a
260
+ * mismatch is not an error, so deriving both from one walk is what keeps them from
261
+ * falling out of step; a column added without a source is a compile error there.
262
+ * What the compiler does NOT reach is whether the source READS the right field, or
263
+ * whether the column comes back out on the read; the round-trip contract group run
264
+ * against this engine is what proves a new column, and only with a value distinct
265
+ * per column, which is what makes a swap visible.
253
266
  */
254
267
  const insertFanoutNotify = (
255
268
  names: ResolvedNames,
@@ -273,18 +286,18 @@ const insertFanoutNotify = (
273
286
  PERFORM pg_notify(${channel}, '1');
274
287
  END IF;`
275
288
  : ''
276
- return ` WITH ne AS (
277
- SELECT type, data, tags, uuid, occurred_at, ordinality
289
+ const ne = RECORDSET_ALIAS
290
+ return ` WITH ${ne} AS (
291
+ SELECT ${eventColumnList}, ordinality
278
292
  FROM ROWS FROM(
279
293
  jsonb_to_recordset(new_events) AS (${eventRecordsetColumns})
280
294
  ) WITH ORDINALITY
281
295
  ),
282
296
  inserted AS (
283
- INSERT INTO ${main} (type, data, tags, uuid, occurred_at)
284
- SELECT ne.type, decode(ne.data, 'base64'), ne.tags, ne.uuid,
285
- ne.occurred_at
286
- FROM ne
287
- ORDER BY ne.ordinality
297
+ INSERT INTO ${main} (${eventColumnList})
298
+ SELECT ${eventInsertSelectList}
299
+ FROM ${ne}
300
+ ORDER BY ${ne}.ordinality
288
301
  RETURNING id, tags
289
302
  ),
290
303
  fanned AS (
@@ -337,9 +350,12 @@ const conditionalAppendBody = (
337
350
  // its ITEMS — none at all, an item carrying no tags, an item carrying tags —
338
351
  // which between them cover every `Query` value the grammar can express, so a new
339
352
  // SHAPE changes which SQL the read compiles without changing what this body has
340
- // to match. The exposure is the GRAMMAR itself: a new `QueryItem` field, or a new
341
- // meaning for an existing one, is something this body ignores rather than fails
342
- // on, and no `switch` over the shape union would have caught that either.
353
+ // to match. The exposure is the GRAMMAR itself: a field ADDED to `QueryItem`, or a
354
+ // new meaning for an existing one, is something this body ignores rather than
355
+ // fails on, and no `switch` over the shape union would have caught that either. A
356
+ // key the grammar does NOT define never reaches this body: core's
357
+ // `servableQueryFault` refuses it at the contract boundary, which is what keeps a
358
+ // smuggled predicate from being dropped here in silence.
343
359
  const after = 'COALESCE(after_id, 0)'
344
360
  return `
345
361
  DECLARE
@@ -537,14 +553,18 @@ export const ddlStatements = (names: ResolvedNames): ReadonlyArray<string> => {
537
553
  // positions — a rolled-back insert permanently consumes its id, which is
538
554
  // fine: ordering is by id alone and the store is never made gapless.
539
555
  // `data` is NULLABLE (a payload-less event is valid); `occurred_at` is
540
- // informational domain time, never an ordering key.
556
+ // informational domain time, never an ordering key. `metadata` is the
557
+ // event's cleartext context bag (ADR-0017): `jsonb` only so a containment
558
+ // index stays possible without a rewrite, though nothing queries it and no
559
+ // index is built; `NOT NULL DEFAULT '{}'` gives an empty bag one shape.
541
560
  `CREATE TABLE IF NOT EXISTS ${main} (
542
561
  id bigserial,
543
562
  type text NOT NULL,
544
563
  data bytea,
545
564
  tags text[] NOT NULL,
546
565
  uuid text NOT NULL,
547
- occurred_at timestamptz NOT NULL
566
+ occurred_at timestamptz NOT NULL,
567
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb
548
568
  ) WITH (${AUTOVACUUM_WITH});`,
549
569
 
550
570
  // Covering unique index: uniqueness on id plus INCLUDE(type) so the read
@@ -39,6 +39,7 @@ const EVENT_COLUMNS = [
39
39
  ['tags', 'text[]'],
40
40
  ['uuid', 'text'],
41
41
  ['occurred_at', 'timestamptz'],
42
+ ['metadata', 'jsonb'],
42
43
  ] as const satisfies ReadonlyArray<
43
44
  readonly [name: string, recordsetType: string]
44
45
  >
@@ -55,7 +56,8 @@ export type EventColumnName = (typeof EVENT_COLUMNS)[number][0]
55
56
 
56
57
  /**
57
58
  * The `jsonb_to_recordset(new_events) AS (…)` column definitions —
58
- * `type text, data text, tags text[], uuid text, occurred_at timestamptz`. The
59
+ * `type text, data text, tags text[], uuid text, occurred_at timestamptz,
60
+ * metadata jsonb`. The
59
61
  * transport `data` is base64 TEXT (restored to `bytea` by `decode(…, 'base64')`
60
62
  * at insert), which is why it is `text` here, not `bytea`.
61
63
  */
@@ -73,6 +75,66 @@ export const readColumns = (prefix = ''): string =>
73
75
  ', ',
74
76
  )
75
77
 
78
+ /**
79
+ * The alias the append body gives the expanded `new_events` recordset — the `ne`
80
+ * CTE in `ddl.ts` — which every expression in `EVENT_COLUMN_SOURCE` reads from.
81
+ * Exported so the CTE is declared under the one name its readers assume.
82
+ */
83
+ export const RECORDSET_ALIAS = 'ne'
84
+
85
+ /**
86
+ * What the append body inserts into each event column, read from the recordset row.
87
+ *
88
+ * Typed as a `Record` over `EventColumnName` so that adding a column to
89
+ * `EVENT_COLUMNS` without saying where its value comes from is a COMPILE error,
90
+ * rather than a column the insert silently leaves at its default. Every column but
91
+ * `data` and `metadata` passes through as the recordset already types it — `text[]`
92
+ * tags, `timestamptz` — because `jsonb_to_recordset` did the casting; `data`
93
+ * crosses as base64 TEXT and is restored to `bytea` here, its one decode.
94
+ *
95
+ * `metadata` is wrapped in `COALESCE(…, '{}'::jsonb)`, for a caller this package's
96
+ * own client never is. `ensureSchema` replaces each append function with
97
+ * `CREATE OR REPLACE`, which keeps its argument signature, so a client from before
98
+ * the column existed (`1.0.0-alpha.3` and earlier) goes on calling the current body
99
+ * with events JSON that has no `metadata` key. `jsonb_to_recordset` reads an absent
100
+ * key as `NULL`, and an insert that names the column inserts that `NULL` rather than
101
+ * the column's `DEFAULT`, so `NOT NULL` would refuse every such append. The
102
+ * `COALESCE` gives that row the empty bag the `DEFAULT` would have, which is what
103
+ * makes "migrate, then deploy" a safe order from those versions. An event sent WITH
104
+ * a bag passes through unchanged, so what is stored for it does not move.
105
+ */
106
+ const EVENT_COLUMN_SOURCE: Record<EventColumnName, string> = {
107
+ type: `${RECORDSET_ALIAS}.type`,
108
+ data: `decode(${RECORDSET_ALIAS}.data, 'base64')`,
109
+ tags: `${RECORDSET_ALIAS}.tags`,
110
+ uuid: `${RECORDSET_ALIAS}.uuid`,
111
+ occurred_at: `${RECORDSET_ALIAS}.occurred_at`,
112
+ metadata: `COALESCE(${RECORDSET_ALIAS}.metadata, '{}'::jsonb)`,
113
+ }
114
+
115
+ /**
116
+ * The bare event column names — `type, data, tags, uuid, occurred_at, metadata` —
117
+ * which the append body uses twice: as the recordset CTE's projection and as the
118
+ * `INSERT INTO t (…)` target list.
119
+ */
120
+ export const eventColumnList: string = EVENT_COLUMNS.map(([name]) => name).join(
121
+ ', ',
122
+ )
123
+
124
+ /**
125
+ * The append insert's SELECT list: one source expression per column, in the SAME
126
+ * ORDER as `eventColumnList`.
127
+ *
128
+ * The two halves of `INSERT INTO t (a, b) SELECT x, y` agree POSITION BY POSITION
129
+ * and nothing checks that they do: two columns of compatible type swap without an
130
+ * error at any layer and the data lands in the wrong one. Both halves are therefore
131
+ * one walk of the one descriptor, so the append body has no second ordering to keep
132
+ * in step and no hand-written list for a new column to be missing from.
133
+ */
134
+ export const eventInsertSelectList: string = EVENT_COLUMNS.map(
135
+ ([name]) => EVENT_COLUMN_SOURCE[name],
136
+ ).join(', ')
137
+
76
138
  /**
77
139
  * The allowed-types filter for a candidate row aliased `m`: an item with no
78
140
  * types matches ANY type; otherwise the event's type must be one of them.
@@ -21,10 +21,11 @@
21
21
  * plpgsql body, where no compiler follows, and `ddl.ts` owns what that rests on
22
22
  * instead.
23
23
  */
24
- import type { SqlClient, SqlError } from '@effect/sql'
24
+
25
25
  import type { Position, ReadLimit } from '@kairos-es/core'
26
26
  import { classifyServableQuery, ORIGIN, type Query } from '@kairos-es/core'
27
27
  import type { Effect } from 'effect'
28
+ import type { SqlClient, SqlError } from 'effect/sql'
28
29
  import { taggedReadSql, typeOnlyReadSql, wildcardReadSql } from './matchSql'
29
30
  import { type EventRow, jsonbText, toQueryItemsJson } from './transport'
30
31
 
@@ -82,11 +83,15 @@ export const compileFetchPlan = (
82
83
  bind: (after, limit) => [after, limit],
83
84
  }
84
85
  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
86
+ // `@effect/sql-pg` binds a JS array of strings as a `text[]` param, so
87
+ // `type = ANY($2)` matches any of the item's types. A branded `EventType` IS
88
+ // its string to the driver, so nothing has to be unwrapped — but the list is
89
+ // COPIED here, at compile time. The driver serialises the array afresh on
90
+ // every `bind`, and a subscription re-binds on every poll, so binding the
91
+ // caller's own array would let an edit the caller makes after subscribing
92
+ // reach the next fetch. The tagged arm is immune by construction, since it
93
+ // serialises once below; this copy is what makes the two arms agree.
94
+ const types = [...shape.types]
90
95
  return {
91
96
  text: typeOnlyReadSql(tables.main),
92
97
  bind: (after, limit) => [after, types, limit],
@@ -8,7 +8,7 @@
8
8
  * where transport code lives, and the two per-column type maps (`EventJson`,
9
9
  * `EventRow`) sit next to each other rather than in two files.
10
10
  *
11
- * The 5-column event contract stays single-sourced on `EventColumnName`
11
+ * The event column contract stays single-sourced on `EventColumnName`
12
12
  * (`internal/matchSql.ts`), the one column-NAME descriptor: both type maps here
13
13
  * are mapped types over it, so a column rename in `EVENT_COLUMNS` is a COMPILE
14
14
  * error in this module, never a silent transport drift the lax read boundary
@@ -19,22 +19,30 @@
19
19
  * (`toEventJson`) serialises `data` as base64 text and domain time as an ISO
20
20
  * string, both restored in SQL.
21
21
  */
22
- import type { DcbEvent, Position, Query, SequencedEvent } from '@kairos-es/core'
23
- import { DateTime, Encoding } from 'effect'
22
+ import type {
23
+ DcbEvent,
24
+ Metadata,
25
+ Position,
26
+ Query,
27
+ SequencedEvent,
28
+ } from '@kairos-es/core'
29
+ import { DateTime } from 'effect'
30
+ import { Base64 } from 'effect/encoding'
24
31
  import type { EventColumnName } from './matchSql'
25
32
 
26
33
  /**
27
34
  * Serialise a value to a JSON STRING for transport as a `::jsonb`-cast bound
28
35
  * parameter.
29
36
  *
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.
37
+ * WHY a string and not `sql.json(value)`: the read plan runs through
38
+ * `sql.unsafe(text, params)` with POSITIONAL parameters, and a positional
39
+ * parameter must be a plain scalar or a flat array of scalars — `@effect/sql-pg`
40
+ * cannot infer a Postgres type for an object or an array of objects, and a
41
+ * `sql.json` fragment is not a value that list can carry. A pre-stringified
42
+ * JSON string binds untyped and the `::jsonb` cast has Postgres parse it, which
43
+ * round-trips reliably for both objects and arrays on the positional path and
44
+ * the tagged-template path alike. Kept as a named helper so every
45
+ * JSON-transport call site is consistent and the reasoning lives in one place.
38
46
  */
39
47
  export const jsonbText = (value: unknown): string => JSON.stringify(value)
40
48
 
@@ -54,9 +62,11 @@ export const toQueryItemsJson = (query: Query): ReadonlyArray<QueryItemJson> =>
54
62
  /**
55
63
  * The per-column TS types of the transport JSON shape — one entry per
56
64
  * `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.
65
+ * (both restored in SQL: `decode(…, 'base64')` / `timestamptz`); `metadata`
66
+ * travels as a nested JSON object, which `jsonb_to_recordset` hands to the
67
+ * insert as `jsonb` untouched; the rest map straight across. Kept as a plain
68
+ * map so `EventJson` below is a mapped type over the ONE column descriptor
69
+ * rather than a hand-restated column list.
60
70
  */
61
71
  interface EventJsonColumns {
62
72
  readonly type: string
@@ -64,6 +74,7 @@ interface EventJsonColumns {
64
74
  readonly tags: ReadonlyArray<string>
65
75
  readonly uuid: string
66
76
  readonly occurred_at: string
77
+ readonly metadata: Metadata
67
78
  }
68
79
 
69
80
  /**
@@ -80,18 +91,25 @@ export type EventJson = { readonly [K in EventColumnName]: EventJsonColumns[K] }
80
91
  /** Serialise one core `DcbEvent` into its transport JSON shape. */
81
92
  export const toEventJson = (event: DcbEvent): EventJson => ({
82
93
  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),
94
+ // `Base64.encode` (from `effect/encoding`) keeps this dependency-free and pure
95
+ // — no `Buffer`, so no `@types/node` and no browser/runtime coupling. It emits
96
+ // the standard RFC 4648 alphabet with `=` padding, which is what plpgsql's
97
+ // `decode(…, 'base64')` reads.
98
+ data: Base64.encode(event.data),
86
99
  tags: [...event.tags],
87
100
  uuid: event.uuid,
88
101
  occurred_at: DateTime.formatIso(event.occurredAt),
102
+ metadata: event.metadata,
89
103
  })
90
104
 
91
105
  /**
92
106
  * 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`
107
+ * name. These are the JS types `@effect/sql-pg`'s default binary codecs decode
108
+ * to: `data` a `Uint8Array` (or null for a payload-less event), `occurred_at` a
109
+ * JS `Date`, `tags` a `string[]`, `metadata` whatever the driver parses a
110
+ * `jsonb` value into — declared
111
+ * `unknown`, because `jsonb` is the one column here whose SQL type does not
112
+ * fix its JS shape, and `metadataFrom` below is where it is checked. `id`
95
113
  * (the `bigserial` position) is NOT here — it is prepended by the read projection
96
114
  * and absent from the events recordset, so it sits outside the shared descriptor.
97
115
  */
@@ -101,23 +119,51 @@ interface EventRowColumns {
101
119
  readonly tags: ReadonlyArray<string>
102
120
  readonly uuid: string
103
121
  readonly occurred_at: Date
122
+ readonly metadata: unknown
104
123
  }
105
124
 
106
125
  /**
107
- * A raw row from the main table as node-postgres returns it, as `id` plus a
126
+ * A raw row from the main table as the driver returns it, as `id` plus a
108
127
  * 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).
128
+ * the driver's default codec hands back as a `bigint` (JS `number` is unsafe past
129
+ * 2^53). `rowToSequenced` still passes it through `BigInt(…)`, so a client
130
+ * configured with its own `int8` codec that yields the decimal string instead
131
+ * still mints a real `bigint` position.
110
132
  * The payload columns cannot drift from the read projection: a rename in
111
133
  * `EVENT_COLUMNS` breaks this mapped type.
112
134
  */
113
- export type EventRow = { readonly id: string } & {
135
+ export type EventRow = { readonly id: bigint | string } & {
114
136
  readonly [K in EventColumnName]: EventRowColumns[K]
115
137
  }
116
138
 
139
+ /**
140
+ * The `metadata` column as the event's bag, checked to be a JSON OBJECT.
141
+ *
142
+ * The store only ever writes an object, but `jsonb` admits any JSON value — an
143
+ * array, a string, a number, a boolean, JSON `null` (which `NOT NULL` does not
144
+ * exclude) — so a hand-written row can hold one. Such a row is corrupt, and
145
+ * handing an event whose `metadata` is not a bag to every projection
146
+ * downstream is worse than stopping at the read. This is the CONTAINER check
147
+ * only; the shape rule itself has one home, core's `Metadata` doc. The VALUES
148
+ * are deliberately not re-checked as strings, because a value check on read
149
+ * alone would make an event the store accepted unreadable for ever.
150
+ */
151
+ const metadataFrom = (value: unknown): Metadata => {
152
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
153
+ throw new Error(
154
+ `@kairos-es/store-postgres: the \`metadata\` column did not hold a JSON object (got ${JSON.stringify(value)}).`,
155
+ )
156
+ }
157
+ return value as Metadata
158
+ }
159
+
117
160
  /**
118
161
  * Map a raw main-table row to a core `SequencedEvent`. `data` NULL → empty bytes
119
162
  * (a payload-less event round-trips to a zero-length `Uint8Array`); `occurred_at`
120
- * Date → `DateTime.Utc`; `id` string → branded `Position`.
163
+ * Date → `DateTime.Utc`; `id` → branded `Position`; `metadata` through
164
+ * `metadataFrom`. The column is `NOT NULL DEFAULT '{}'`, so there is no absent
165
+ * case to reconcile: an event appended with an empty bag reads back as `{}` on
166
+ * this engine exactly as on the in-memory oracle.
121
167
  *
122
168
  * The branded scalars (`type`, `tags`, `uuid`, `position`) are minted by trusted
123
169
  * `as`-cast, NOT by re-running `EventType.make` / `Tag.make` etc. This is
@@ -135,7 +181,8 @@ export const rowToSequenced = (row: EventRow): SequencedEvent => ({
135
181
  data: row.data ?? new Uint8Array(0),
136
182
  tags: row.tags as unknown as DcbEvent['tags'],
137
183
  uuid: row.uuid as DcbEvent['uuid'],
138
- occurredAt: DateTime.unsafeFromDate(row.occurred_at),
184
+ occurredAt: DateTime.fromDateUnsafe(row.occurred_at),
185
+ metadata: metadataFrom(row.metadata),
139
186
  },
140
187
  position: BigInt(row.id) as Position,
141
188
  })