@kairos-es/store-postgres 1.0.0-alpha.4 → 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 (86) hide show
  1. package/README.md +54 -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 +2 -2
  7. package/dist/cjs/ensureSchema.js.map +1 -1
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/internal/matchSql.js +15 -4
  10. package/dist/cjs/internal/matchSql.js.map +1 -1
  11. package/dist/cjs/internal/readPlan.js +25 -1
  12. package/dist/cjs/internal/readPlan.js.map +1 -1
  13. package/dist/cjs/internal/transport.js +17 -13
  14. package/dist/cjs/internal/transport.js.map +1 -1
  15. package/dist/cjs/store.js +30 -25
  16. package/dist/cjs/store.js.map +1 -1
  17. package/dist/dts-cjs/clients.d.ts +66 -0
  18. package/dist/dts-cjs/clients.d.ts.map +1 -0
  19. package/dist/{dts → dts-cjs}/config.d.ts +9 -19
  20. package/dist/{dts → dts-cjs}/config.d.ts.map +1 -1
  21. package/dist/{dts → dts-cjs}/ensureSchema.d.ts +2 -2
  22. package/dist/dts-cjs/ensureSchema.d.ts.map +1 -0
  23. package/dist/{dts → dts-cjs}/index.d.ts +4 -3
  24. package/dist/dts-cjs/index.d.ts.map +1 -0
  25. package/dist/{dts → dts-cjs}/internal/ddl.d.ts +1 -1
  26. package/dist/{dts → dts-cjs}/internal/ddl.d.ts.map +1 -1
  27. package/dist/{dts → dts-cjs}/internal/matchSql.d.ts.map +1 -1
  28. package/dist/{dts → dts-cjs}/internal/readPlan.d.ts +1 -1
  29. package/dist/{dts → dts-cjs}/internal/readPlan.d.ts.map +1 -1
  30. package/dist/{dts → dts-cjs}/internal/transport.d.ts +20 -15
  31. package/dist/dts-cjs/internal/transport.d.ts.map +1 -0
  32. package/dist/dts-cjs/package.json +3 -0
  33. package/dist/dts-cjs/store.d.ts.map +1 -0
  34. package/dist/dts-esm/clients.d.ts +66 -0
  35. package/dist/dts-esm/clients.d.ts.map +1 -0
  36. package/dist/dts-esm/config.d.ts +48 -0
  37. package/dist/dts-esm/config.d.ts.map +1 -0
  38. package/dist/dts-esm/ensureSchema.d.ts +40 -0
  39. package/dist/dts-esm/ensureSchema.d.ts.map +1 -0
  40. package/dist/dts-esm/index.d.ts +42 -0
  41. package/dist/dts-esm/index.d.ts.map +1 -0
  42. package/dist/dts-esm/internal/ddl.d.ts +174 -0
  43. package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
  44. package/dist/dts-esm/internal/matchSql.d.ts +164 -0
  45. package/dist/dts-esm/internal/matchSql.d.ts.map +1 -0
  46. package/dist/dts-esm/internal/readPlan.d.ts +72 -0
  47. package/dist/dts-esm/internal/readPlan.d.ts.map +1 -0
  48. package/dist/dts-esm/internal/transport.d.ts +131 -0
  49. package/dist/dts-esm/internal/transport.d.ts.map +1 -0
  50. package/dist/dts-esm/package.json +3 -0
  51. package/dist/dts-esm/store.d.ts +17 -0
  52. package/dist/dts-esm/store.d.ts.map +1 -0
  53. package/dist/esm/clients.js +34 -24
  54. package/dist/esm/clients.js.map +1 -1
  55. package/dist/esm/config.js +9 -15
  56. package/dist/esm/config.js.map +1 -1
  57. package/dist/esm/ensureSchema.js +2 -2
  58. package/dist/esm/ensureSchema.js.map +1 -1
  59. package/dist/esm/index.js +4 -3
  60. package/dist/esm/index.js.map +1 -1
  61. package/dist/esm/internal/matchSql.js +15 -4
  62. package/dist/esm/internal/matchSql.js.map +1 -1
  63. package/dist/esm/internal/readPlan.js +24 -1
  64. package/dist/esm/internal/readPlan.js.map +1 -1
  65. package/dist/esm/internal/transport.js +18 -14
  66. package/dist/esm/internal/transport.js.map +1 -1
  67. package/dist/esm/store.js +30 -25
  68. package/dist/esm/store.js.map +1 -1
  69. package/package.json +13 -11
  70. package/src/clients.ts +43 -31
  71. package/src/config.ts +12 -13
  72. package/src/ensureSchema.ts +2 -2
  73. package/src/index.ts +4 -3
  74. package/src/internal/ddl.ts +1 -1
  75. package/src/internal/matchSql.ts +15 -4
  76. package/src/internal/readPlan.ts +3 -2
  77. package/src/internal/transport.ts +28 -20
  78. package/src/store.ts +35 -28
  79. package/dist/dts/clients.d.ts +0 -34
  80. package/dist/dts/clients.d.ts.map +0 -1
  81. package/dist/dts/ensureSchema.d.ts.map +0 -1
  82. package/dist/dts/index.d.ts.map +0 -1
  83. package/dist/dts/internal/transport.d.ts.map +0 -1
  84. package/dist/dts/store.d.ts.map +0 -1
  85. /package/dist/{dts → dts-cjs}/internal/matchSql.d.ts +0 -0
  86. /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
@@ -28,8 +28,8 @@
28
28
  * bodies carry no bound values (pure schema), and the append functions keep
29
29
  * their runtime VALUES as bound parameters, never interpolated.
30
30
  */
31
- import { SqlClient, type SqlError } from '@effect/sql'
32
31
  import { Effect } from 'effect'
32
+ import { SqlClient, type SqlError } from 'effect/sql'
33
33
  import { decodePostgresStoreConfig, type PostgresStoreConfig } from './config'
34
34
  import { resolveNames, runDdl } from './internal/ddl'
35
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,7 +25,7 @@
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
31
  eventColumnList,
@@ -88,9 +88,20 @@ export const RECORDSET_ALIAS = 'ne'
88
88
  * Typed as a `Record` over `EventColumnName` so that adding a column to
89
89
  * `EVENT_COLUMNS` without saying where its value comes from is a COMPILE error,
90
90
  * rather than a column the insert silently leaves at its default. Every column but
91
- * `data` passes through as the recordset already types it — `text[]` tags,
92
- * `timestamptz`, `jsonb` metadata — because `jsonb_to_recordset` did the casting;
93
- * `data` crosses as base64 TEXT and is restored to `bytea` here, its one decode.
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.
94
105
  */
95
106
  const EVENT_COLUMN_SOURCE: Record<EventColumnName, string> = {
96
107
  type: `${RECORDSET_ALIAS}.type`,
@@ -98,7 +109,7 @@ const EVENT_COLUMN_SOURCE: Record<EventColumnName, string> = {
98
109
  tags: `${RECORDSET_ALIAS}.tags`,
99
110
  uuid: `${RECORDSET_ALIAS}.uuid`,
100
111
  occurred_at: `${RECORDSET_ALIAS}.occurred_at`,
101
- metadata: `${RECORDSET_ALIAS}.metadata`,
112
+ metadata: `COALESCE(${RECORDSET_ALIAS}.metadata, '{}'::jsonb)`,
102
113
  }
103
114
 
104
115
  /**
@@ -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,7 +83,7 @@ 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
+ // `@effect/sql-pg` binds a JS array of strings as a `text[]` param, so
86
87
  // `type = ANY($2)` matches any of the item's types. A branded `EventType` IS
87
88
  // its string to the driver, so nothing has to be unwrapped — but the list is
88
89
  // COPIED here, at compile time. The driver serialises the array afresh on
@@ -26,21 +26,23 @@ import type {
26
26
  Query,
27
27
  SequencedEvent,
28
28
  } from '@kairos-es/core'
29
- import { DateTime, Encoding } from 'effect'
29
+ import { DateTime } from 'effect'
30
+ import { Base64 } from 'effect/encoding'
30
31
  import type { EventColumnName } from './matchSql'
31
32
 
32
33
  /**
33
34
  * Serialise a value to a JSON STRING for transport as a `::jsonb`-cast bound
34
35
  * parameter.
35
36
  *
36
- * WHY not `sql.json(value)`: `@effect/sql-pg`'s `json` helper binds the RAW
37
- * value, and node-postgres serialises a JS *array* bound param as a Postgres
38
- * ARRAY literal (`{…}`), not JSON — so `sql.json([...])::jsonb` fails with a
39
- * `22P02` invalid-JSON error (an array literal is not valid JSON). Passing a
40
- * pre-stringified JSON string and casting `::jsonb` sends plain text that
41
- * Postgres parses as JSON, which round-trips reliably for both objects and
42
- * arrays. Kept as a named helper so every JSON-transport call site is consistent
43
- * 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.
44
46
  */
45
47
  export const jsonbText = (value: unknown): string => JSON.stringify(value)
46
48
 
@@ -89,9 +91,11 @@ export type EventJson = { readonly [K in EventColumnName]: EventJsonColumns[K] }
89
91
  /** Serialise one core `DcbEvent` into its transport JSON shape. */
90
92
  export const toEventJson = (event: DcbEvent): EventJson => ({
91
93
  type: event.type,
92
- // `Encoding.encodeBase64` (from `effect`) keeps this dependency-free and pure
93
- // — no `Buffer`, so no `@types/node` and no browser/runtime coupling.
94
- 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),
95
99
  tags: [...event.tags],
96
100
  uuid: event.uuid,
97
101
  occurred_at: DateTime.formatIso(event.occurredAt),
@@ -100,9 +104,10 @@ export const toEventJson = (event: DcbEvent): EventJson => ({
100
104
 
101
105
  /**
102
106
  * The per-column TS types of a raw driver row — one entry per `EVENT_COLUMNS`
103
- * name. These are node-postgres's decoded JS types: `data` a `Buffer` (or null
104
- * for a payload-less event), `occurred_at` a JS `Date`, `tags` a `string[]`,
105
- * `metadata` whatever node-postgres parses a `jsonb` value into — declared
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
106
111
  * `unknown`, because `jsonb` is the one column here whose SQL type does not
107
112
  * fix its JS shape, and `metadataFrom` below is where it is checked. `id`
108
113
  * (the `bigserial` position) is NOT here — it is prepended by the read projection
@@ -118,13 +123,16 @@ interface EventRowColumns {
118
123
  }
119
124
 
120
125
  /**
121
- * 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
122
127
  * mapped type over the shared `EventColumnName` descriptor. `id` is `int8`, which
123
- * 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.
124
132
  * The payload columns cannot drift from the read projection: a rename in
125
133
  * `EVENT_COLUMNS` breaks this mapped type.
126
134
  */
127
- export type EventRow = { readonly id: string } & {
135
+ export type EventRow = { readonly id: bigint | string } & {
128
136
  readonly [K in EventColumnName]: EventRowColumns[K]
129
137
  }
130
138
 
@@ -152,7 +160,7 @@ const metadataFrom = (value: unknown): Metadata => {
152
160
  /**
153
161
  * Map a raw main-table row to a core `SequencedEvent`. `data` NULL → empty bytes
154
162
  * (a payload-less event round-trips to a zero-length `Uint8Array`); `occurred_at`
155
- * Date → `DateTime.Utc`; `id` string → branded `Position`; `metadata` through
163
+ * Date → `DateTime.Utc`; `id` → branded `Position`; `metadata` through
156
164
  * `metadataFrom`. The column is `NOT NULL DEFAULT '{}'`, so there is no absent
157
165
  * case to reconcile: an event appended with an empty bag reads back as `{}` on
158
166
  * this engine exactly as on the in-memory oracle.
@@ -173,7 +181,7 @@ export const rowToSequenced = (row: EventRow): SequencedEvent => ({
173
181
  data: row.data ?? new Uint8Array(0),
174
182
  tags: row.tags as unknown as DcbEvent['tags'],
175
183
  uuid: row.uuid as DcbEvent['uuid'],
176
- occurredAt: DateTime.unsafeFromDate(row.occurred_at),
184
+ occurredAt: DateTime.fromDateUnsafe(row.occurred_at),
177
185
  metadata: metadataFrom(row.metadata),
178
186
  },
179
187
  position: BigInt(row.id) as Position,
package/src/store.ts CHANGED
@@ -40,7 +40,6 @@
40
40
  * (`packages/core/src/SubscribeMachine.ts`), shared by every engine, and this
41
41
  * module only hands it a `fetch` and a `listen` built from the two clients.
42
42
  */
43
- import type { SqlClient, SqlError } from '@effect/sql'
44
43
  import type {
45
44
  DcbEvent,
46
45
  Position,
@@ -61,6 +60,7 @@ import {
61
60
  subscribeStream,
62
61
  } from '@kairos-es/core'
63
62
  import { type Array as Arr, Effect, Layer, type Scope, Stream } from 'effect'
63
+ import type { SqlClient, SqlError } from 'effect/sql'
64
64
  import { PgClientDirect, PgClientPooled } from './clients'
65
65
  import { decodePostgresStoreConfig, type PostgresStoreConfig } from './config'
66
66
  import {
@@ -138,15 +138,19 @@ const make = (
138
138
  * Nor would such a parameter be load-bearing for the REPEATABLE READ
139
139
  * transaction this runs inside, which is the trap worth naming, since a
140
140
  * closed-over client LOOKS like it must escape to its own snapshot.
141
- * `@effect/sql` resolves a statement's connection when the statement RUNS,
142
- * from the FIBRE CONTEXT rather than from the client value: `withTransaction`
143
- * acquires one connection and `Effect.locally`s it into
144
- * `FiberRef.currentContext` under its `TransactionConnection` tag, and every
145
- * statement a client builds resolves through a `getConnection` that reads
146
- * that tag first, falling back to the pool acquirer only when it is absent
147
- * (`@effect/sql@0.52` `src/internal/client.ts`). A statement therefore joins
148
- * a transaction by being RUN inside it, and passing the client along the call
149
- * chain neither adds to that nor is required by it. `read-postgres`'s
141
+ * `effect/sql` resolves a statement's connection when the statement RUNS,
142
+ * from the FIBRE CONTEXT: `withTransaction` acquires one connection and
143
+ * provides it under that client's OWN `TransactionConnection` key — one key
144
+ * per `SqlClient` instance — and every statement that client builds resolves
145
+ * through a `getConnection` that reads that key first, falling back to the
146
+ * pool only when it is absent (`effect/sql`'s `SqlClient.make`). So a
147
+ * statement joins a transaction by being RUN inside it AND by being built from
148
+ * the SAME client value that opened it; a statement from any other client,
149
+ * even one over the same database, runs on its own pooled connection outside
150
+ * the transaction. Here every statement in the frame — the isolation `SET`,
151
+ * the row fetch and this `MAX(id)` — comes from `pooled`, the client that
152
+ * opened it, so all three share its connection and its snapshot, and passing
153
+ * the client along the call chain would add nothing. `read-postgres`'s
150
154
  * `pinnedConnection` test observes the same fact from the SERVER: two
151
155
  * statements issued inside one transaction report one `pg_backend_pid()`.
152
156
  *
@@ -154,7 +158,7 @@ const make = (
154
158
  * supports: it carries no mutable state and recompiles on every execution.
155
159
  */
156
160
  const globalHead: Effect.Effect<Position, SqlError.SqlError> = pooled<{
157
- readonly max: string | null
161
+ readonly max: bigint | string | null
158
162
  }>`
159
163
  SELECT MAX(id) AS max FROM ${pooled(names.mainTable)}
160
164
  `.pipe(
@@ -251,7 +255,7 @@ const make = (
251
255
  * One row of an append function's result: the new head (`MAX(id)`), or NULL /
252
256
  * zero rows for the empty/conflict cases the callers interpret.
253
257
  */
254
- type AppendHeadRow = { readonly max: string | null }
258
+ type AppendHeadRow = { readonly max: bigint | string | null }
255
259
 
256
260
  /**
257
261
  * The ONE transaction frame every append rides: open a transaction, apply the
@@ -266,7 +270,7 @@ const make = (
266
270
  select: Effect.Effect<ReadonlyArray<AppendHeadRow>, SqlError.SqlError>,
267
271
  ): Effect.Effect<ReadonlyArray<AppendHeadRow>, SqlError.SqlError> =>
268
272
  pooled.withTransaction(
269
- setLocalLockTimeout(pooled, lockTimeout).pipe(Effect.zipRight(select)),
273
+ setLocalLockTimeout(pooled, lockTimeout).pipe(Effect.andThen(select)),
270
274
  )
271
275
 
272
276
  const append = (
@@ -344,22 +348,23 @@ const make = (
344
348
  /**
345
349
  * Wire core's subscribe machine to the live clients: `fetch` is the
346
350
  * per-subscription compiled read plan run against the pooled client and mapped
347
- * to `SequencedEvent`s; `listen` is the direct client's ref-counted, non-pooled
348
- * LISTEN on this store's channel (the Neon-safe path — `clients.ts` owns why
349
- * there are two clients at all). The plan is compiled ONCE here so the poll
350
- * loop re-binds only the cursor.
351
+ * to `SequencedEvent`s; `listen` is the direct client's LISTEN on this store's
352
+ * channel (the Neon-safe path — `clients.ts` owns why there are two clients at
353
+ * all), which reserves one of that client's connections for as long as the
354
+ * subscription's wake fibre runs and drains its notification queue as a
355
+ * stream. The plan is compiled ONCE here so the poll loop re-binds only the
356
+ * cursor.
351
357
  *
352
358
  * The wake source is `NOTIFY`, and it is a LATENCY optimisation only: the
353
- * machine's periodic position-based poll is what makes delivery correct, which
354
- * matters more here than the general argument for it suggests. The
355
- * `@effect/sql-pg` listen client SWALLOWS connection errors and does not
356
- * auto-reconnect, so a dropped LISTEN typically goes quiet rather than
357
- * erroring — the machine's listen-side retry then never fires, and the poll is
358
- * the ONLY thing that recovers the subscription. The same silence is what a
359
- * Neon pooled endpoint produces if `PgClientDirect` is misconfigured: the
360
- * pooler drops `LISTEN`/`NOTIFY` without complaint and delivery degrades to
361
- * poll-rate rather than failing, which is why that trap is a deployment-time
362
- * wiring choice and not a runtime error.
359
+ * machine's periodic position-based poll is what makes delivery correct. A
360
+ * listening connection that DROPS fails its queue with the `SqlError` it died
361
+ * of, so the machine's listen-side retry re-subscribes on a fresh connection
362
+ * — but the notifications sent while it was down are gone, and only the poll
363
+ * recovers the events they announced. And some failures are silent: a Neon
364
+ * pooled endpoint, if `PgClientDirect` is misconfigured, accepts `LISTEN` and
365
+ * then never delivers a `NOTIFY`, so delivery degrades to poll-rate rather
366
+ * than failing, which is why that trap is a deployment-time wiring choice and
367
+ * not a runtime error.
363
368
  *
364
369
  * `E` is `SqlError.SqlError` — both seams fail with it, so the machine's single
365
370
  * error parameter carries it straight through to `makeContractStore`.
@@ -375,7 +380,9 @@ const make = (
375
380
  runFetch(pooled, plan, from, limit).pipe(
376
381
  Effect.map((rows) => rows.map(rowToSequenced)),
377
382
  ),
378
- listen: direct.listen(names.channel),
383
+ listen: Stream.unwrap(
384
+ Effect.map(direct.listen(names.channel), Stream.fromQueue),
385
+ ),
379
386
  pollInterval,
380
387
  catchUpPageSize,
381
388
  },
@@ -1,34 +0,0 @@
1
- import type { SqlError } from '@effect/sql';
2
- import { PgClient } from '@effect/sql-pg';
3
- import { Context, Layer } from 'effect';
4
- declare const PgClientPooled_base: Context.TagClass<PgClientPooled, "@kairos-es/store-postgres/PgClientPooled", PgClient.PgClient>;
5
- /**
6
- * The pooled client tag: reads, the append function, and `notify`. Holds a
7
- * `PgClient` value rather than being a `PgClient.layer`, so it never collides
8
- * with the direct client on the shared `PgClient`/`SqlClient` tag.
9
- */
10
- export declare class PgClientPooled extends PgClientPooled_base {
11
- }
12
- declare const PgClientDirect_base: Context.TagClass<PgClientDirect, "@kairos-es/store-postgres/PgClientDirect", PgClient.PgClient>;
13
- /**
14
- * The direct (non-pooler) client tag: the `subscribe` listener ONLY. Point this
15
- * at a direct endpoint on Neon so `LISTEN`/`NOTIFY` is not dropped by the pooler.
16
- */
17
- export declare class PgClientDirect extends PgClientDirect_base {
18
- }
19
- /**
20
- * Build a `PgClientPooled` layer from a `PgClient` config. We construct the
21
- * client via `PgClient.make` (scoped) and place it in our OWN tag, then provide
22
- * `Reactivity.layer` (which `PgClient.make` requires) — exactly what the
23
- * built-in `PgClient.layer` does internally, minus the collision-prone claim on
24
- * the shared `PgClient` tag.
25
- */
26
- export declare const pooledLayer: (config: PgClient.PgClientConfig) => Layer.Layer<PgClientPooled, SqlError.SqlError>;
27
- /**
28
- * Build a `PgClientDirect` layer from a `PgClient` config. Same construction as
29
- * `pooledLayer`; the two differ only in which endpoint the caller points them
30
- * at (and that only matters on Neon).
31
- */
32
- export declare const directLayer: (config: PgClient.PgClientConfig) => Layer.Layer<PgClientDirect, SqlError.SqlError>;
33
- export {};
34
- //# sourceMappingURL=clients.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"clients.d.ts","sourceRoot":"","sources":["../../src/clients.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;;AAEvC;;;;GAIG;AACH,qBAAa,cAAe,SAAQ,mBAEE;CAAG;;AAEzC;;;GAGG;AACH,qBAAa,cAAe,SAAQ,mBAEE;CAAG;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,QAAQ,CAAC,cAAc,KAC9B,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,QAAQ,CAAC,QAAQ,CAG7C,CAAA;AAEH;;;;GAIG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,QAAQ,CAAC,cAAc,KAC9B,KAAK,CAAC,KAAK,CAAC,cAAc,EAAE,QAAQ,CAAC,QAAQ,CAG7C,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ensureSchema.d.ts","sourceRoot":"","sources":["../../src/ensureSchema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EAAE,SAAS,EAAE,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtD,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAG9E;;;;GAIG;AACH,eAAO,MAAM,YAAY,GACvB,QAAQ,mBAAmB,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,CAAC,QAAQ,EAAE,SAAS,CAAC,SAAS,CAQzD,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,OAAO,EACL,WAAW,EACX,cAAc,EACd,cAAc,EACd,WAAW,GACZ,MAAM,WAAW,CAAA;AAClB,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAA;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAC7C,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../../src/internal/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,EACV,QAAQ,EACR,QAAQ,EAER,KAAK,EACL,cAAc,EACf,MAAM,iBAAiB,CAAA;AAExB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAEjD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,MAA+B,CAAA;AAE1E,4EAA4E;AAC5E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACrC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;CACrC;AAED,mEAAmE;AACnE,eAAO,MAAM,gBAAgB,GAAI,OAAO,KAAK,KAAG,aAAa,CAAC,aAAa,CAItE,CAAA;AAEL;;;;;;;;GAQG;AACH,UAAU,gBAAgB;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;CAC5B;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,QAAQ,EAAE,CAAC,IAAI,eAAe,GAAG,gBAAgB,CAAC,CAAC,CAAC;CAAE,CAAA;AAEhF,mEAAmE;AACnE,eAAO,MAAM,WAAW,GAAI,OAAO,QAAQ,KAAG,SAS5C,CAAA;AAEF;;;;;;;;;GASG;AACH,UAAU,eAAe;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAA;IAChC,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,CAAA;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAA;IAC1B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAA;CAC3B;AAED;;;;;;GAMG;AACH,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG;IAC/C,QAAQ,EAAE,CAAC,IAAI,eAAe,GAAG,eAAe,CAAC,CAAC,CAAC;CACpD,CAAA;AAuBD;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,QAAQ,KAAG,cAU7C,CAAA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/store.ts"],"names":[],"mappings":"AAkDA,OAAO,EAGL,aAAa,EAQd,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAA6B,KAAK,EAAsB,MAAM,QAAQ,CAAA;AAC7E,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAC1D,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,UAAU,CAAA;AAoU9E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,KAAK,GAChB,SAAQ,mBAAwB,KAC/B,KAAK,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,EAAE,cAAc,GAAG,cAAc,CASnE,CAAA"}
File without changes
File without changes