@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.
- package/README.md +61 -29
- package/dist/cjs/clients.js +34 -24
- package/dist/cjs/clients.js.map +1 -1
- package/dist/cjs/config.js +9 -15
- package/dist/cjs/config.js.map +1 -1
- package/dist/cjs/ensureSchema.js +8 -3
- package/dist/cjs/ensureSchema.js.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/internal/ddl.js +29 -12
- package/dist/cjs/internal/ddl.js.map +1 -1
- package/dist/cjs/internal/matchSql.js +57 -4
- package/dist/cjs/internal/matchSql.js.map +1 -1
- package/dist/cjs/internal/readPlan.js +33 -5
- package/dist/cjs/internal/readPlan.js.map +1 -1
- package/dist/cjs/internal/transport.js +42 -15
- package/dist/cjs/internal/transport.js.map +1 -1
- package/dist/cjs/store.js +30 -25
- package/dist/cjs/store.js.map +1 -1
- package/dist/dts-cjs/clients.d.ts +66 -0
- package/dist/dts-cjs/clients.d.ts.map +1 -0
- package/dist/{dts → dts-cjs}/config.d.ts +9 -19
- package/dist/{dts → dts-cjs}/config.d.ts.map +1 -1
- package/dist/{dts → dts-cjs}/ensureSchema.d.ts +8 -3
- package/dist/dts-cjs/ensureSchema.d.ts.map +1 -0
- package/dist/{dts → dts-cjs}/index.d.ts +4 -3
- package/dist/dts-cjs/index.d.ts.map +1 -0
- package/dist/{dts → dts-cjs}/internal/ddl.d.ts +1 -1
- package/dist/{dts → dts-cjs}/internal/ddl.d.ts.map +1 -1
- package/dist/{dts → dts-cjs}/internal/matchSql.d.ts +26 -2
- package/dist/dts-cjs/internal/matchSql.d.ts.map +1 -0
- package/dist/{dts → dts-cjs}/internal/readPlan.d.ts +1 -1
- package/dist/{dts → dts-cjs}/internal/readPlan.d.ts.map +1 -1
- package/dist/{dts → dts-cjs}/internal/transport.d.ts +34 -19
- package/dist/dts-cjs/internal/transport.d.ts.map +1 -0
- package/dist/dts-cjs/package.json +3 -0
- package/dist/dts-cjs/store.d.ts.map +1 -0
- package/dist/dts-esm/clients.d.ts +66 -0
- package/dist/dts-esm/clients.d.ts.map +1 -0
- package/dist/dts-esm/config.d.ts +48 -0
- package/dist/dts-esm/config.d.ts.map +1 -0
- package/dist/dts-esm/ensureSchema.d.ts +40 -0
- package/dist/dts-esm/ensureSchema.d.ts.map +1 -0
- package/dist/dts-esm/index.d.ts +42 -0
- package/dist/dts-esm/index.d.ts.map +1 -0
- package/dist/dts-esm/internal/ddl.d.ts +174 -0
- package/dist/dts-esm/internal/ddl.d.ts.map +1 -0
- package/dist/dts-esm/internal/matchSql.d.ts +164 -0
- package/dist/dts-esm/internal/matchSql.d.ts.map +1 -0
- package/dist/dts-esm/internal/readPlan.d.ts +72 -0
- package/dist/dts-esm/internal/readPlan.d.ts.map +1 -0
- package/dist/dts-esm/internal/transport.d.ts +131 -0
- package/dist/dts-esm/internal/transport.d.ts.map +1 -0
- package/dist/dts-esm/package.json +3 -0
- package/dist/dts-esm/store.d.ts +17 -0
- package/dist/dts-esm/store.d.ts.map +1 -0
- package/dist/esm/clients.js +34 -24
- package/dist/esm/clients.js.map +1 -1
- package/dist/esm/config.js +9 -15
- package/dist/esm/config.js.map +1 -1
- package/dist/esm/ensureSchema.js +8 -3
- package/dist/esm/ensureSchema.js.map +1 -1
- package/dist/esm/index.js +4 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/internal/ddl.js +30 -13
- package/dist/esm/internal/ddl.js.map +1 -1
- package/dist/esm/internal/matchSql.js +55 -2
- package/dist/esm/internal/matchSql.js.map +1 -1
- package/dist/esm/internal/readPlan.js +32 -5
- package/dist/esm/internal/readPlan.js.map +1 -1
- package/dist/esm/internal/transport.js +42 -15
- package/dist/esm/internal/transport.js.map +1 -1
- package/dist/esm/store.js +30 -25
- package/dist/esm/store.js.map +1 -1
- package/package.json +13 -11
- package/src/clients.ts +43 -31
- package/src/config.ts +12 -13
- package/src/ensureSchema.ts +8 -3
- package/src/index.ts +4 -3
- package/src/internal/ddl.ts +33 -13
- package/src/internal/matchSql.ts +63 -1
- package/src/internal/readPlan.ts +11 -6
- package/src/internal/transport.ts +71 -24
- package/src/store.ts +35 -28
- package/dist/dts/clients.d.ts +0 -34
- package/dist/dts/clients.d.ts.map +0 -1
- package/dist/dts/ensureSchema.d.ts.map +0 -1
- package/dist/dts/index.d.ts.map +0 -1
- package/dist/dts/internal/matchSql.d.ts.map +0 -1
- package/dist/dts/internal/transport.d.ts.map +0 -1
- package/dist/dts/store.d.ts.map +0 -1
- /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`)
|
|
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
|
|
7
|
-
* define two DISTINCT `Context.
|
|
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`.
|
|
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`
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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`
|
|
40
|
+
* with the direct client on the shared `PgClient`/`SqlClient` key.
|
|
32
41
|
*/
|
|
33
|
-
export class PgClientPooled extends Context.
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
39
|
-
* at a direct endpoint on Neon so `LISTEN`/`NOTIFY` is not dropped by the
|
|
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.
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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`
|
|
62
|
+
* the shared `PgClient` key.
|
|
51
63
|
*/
|
|
52
64
|
export const pooledLayer = (
|
|
53
|
-
config: PgClient.
|
|
65
|
+
config: PgClient.PgPoolConfig,
|
|
54
66
|
): Layer.Layer<PgClientPooled, SqlError.SqlError> =>
|
|
55
|
-
Layer.
|
|
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.
|
|
77
|
+
config: PgClient.PgPoolConfig,
|
|
66
78
|
): Layer.Layer<PgClientDirect, SqlError.SqlError> =>
|
|
67
|
-
Layer.
|
|
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.
|
|
32
|
-
Schema.
|
|
33
|
-
Schema.
|
|
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
|
-
// `
|
|
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.
|
|
70
|
-
tablePrefix: Schema.
|
|
71
|
-
lockTimeout: Schema.
|
|
72
|
-
Schema.Number.
|
|
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
|
-
}).
|
|
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
|
|
82
|
-
Schema.
|
|
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
|
-
* `
|
|
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 =
|
package/src/ensureSchema.ts
CHANGED
|
@@ -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`
|
|
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
|
|
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
|
|
34
|
-
* assumes `ensureSchema` has run. `ensureSchema` requires the generic
|
|
35
|
-
*
|
|
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,
|
package/src/internal/ddl.ts
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
277
|
-
|
|
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} (
|
|
284
|
-
SELECT
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
341
|
-
// meaning for an existing one, is something this body ignores rather than
|
|
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
|
package/src/internal/matchSql.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/internal/readPlan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
86
|
-
// `type = ANY($2)` matches any of the item's types.
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
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
|
|
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 {
|
|
23
|
-
|
|
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)`:
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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`);
|
|
58
|
-
*
|
|
59
|
-
* the
|
|
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
|
-
// `
|
|
84
|
-
// — no `Buffer`, so no `@types/node` and no browser/runtime coupling.
|
|
85
|
-
|
|
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
|
|
94
|
-
* for a payload-less event), `occurred_at` a
|
|
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
|
|
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
|
|
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`
|
|
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.
|
|
184
|
+
occurredAt: DateTime.fromDateUnsafe(row.occurred_at),
|
|
185
|
+
metadata: metadataFrom(row.metadata),
|
|
139
186
|
},
|
|
140
187
|
position: BigInt(row.id) as Position,
|
|
141
188
|
})
|