@kairos-es/store-postgres 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +167 -0
- package/dist/cjs/clients.js +62 -0
- package/dist/cjs/clients.js.map +1 -0
- package/dist/cjs/config.js +94 -0
- package/dist/cjs/config.js.map +1 -0
- package/dist/cjs/ensureSchema.js +53 -0
- package/dist/cjs/ensureSchema.js.map +1 -0
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/internal/ddl.js +436 -0
- package/dist/cjs/internal/ddl.js.map +1 -0
- package/dist/cjs/internal/matchSql.js +189 -0
- package/dist/cjs/internal/matchSql.js.map +1 -0
- package/dist/cjs/internal/readPlan.js +67 -0
- package/dist/cjs/internal/readPlan.js.map +1 -0
- package/dist/cjs/internal/subscribe.js +120 -0
- package/dist/cjs/internal/subscribe.js.map +1 -0
- package/dist/cjs/internal/transport.js +66 -0
- package/dist/cjs/internal/transport.js.map +1 -0
- package/dist/cjs/store.js +256 -0
- package/dist/cjs/store.js.map +1 -0
- package/dist/dts/clients.d.ts +34 -0
- package/dist/dts/clients.d.ts.map +1 -0
- package/dist/dts/config.d.ts +58 -0
- package/dist/dts/config.d.ts.map +1 -0
- package/dist/dts/ensureSchema.d.ts +35 -0
- package/dist/dts/ensureSchema.d.ts.map +1 -0
- package/dist/dts/index.d.ts +41 -0
- package/dist/dts/index.d.ts.map +1 -0
- package/dist/dts/internal/ddl.d.ts +174 -0
- package/dist/dts/internal/ddl.d.ts.map +1 -0
- package/dist/dts/internal/matchSql.d.ts +140 -0
- package/dist/dts/internal/matchSql.d.ts.map +1 -0
- package/dist/dts/internal/readPlan.d.ts +72 -0
- package/dist/dts/internal/readPlan.d.ts.map +1 -0
- package/dist/dts/internal/subscribe.d.ts +87 -0
- package/dist/dts/internal/subscribe.d.ts.map +1 -0
- package/dist/dts/internal/transport.d.ts +116 -0
- package/dist/dts/internal/transport.d.ts.map +1 -0
- package/dist/dts/store.d.ts +17 -0
- package/dist/dts/store.d.ts.map +1 -0
- package/dist/esm/clients.js +51 -0
- package/dist/esm/clients.js.map +1 -0
- package/dist/esm/config.js +87 -0
- package/dist/esm/config.js.map +1 -0
- package/dist/esm/ensureSchema.js +45 -0
- package/dist/esm/ensureSchema.js.map +1 -0
- package/dist/esm/index.js +40 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/ddl.js +424 -0
- package/dist/esm/internal/ddl.js.map +1 -0
- package/dist/esm/internal/matchSql.js +176 -0
- package/dist/esm/internal/matchSql.js.map +1 -0
- package/dist/esm/internal/readPlan.js +59 -0
- package/dist/esm/internal/readPlan.js.map +1 -0
- package/dist/esm/internal/subscribe.js +113 -0
- package/dist/esm/internal/subscribe.js.map +1 -0
- package/dist/esm/internal/transport.js +56 -0
- package/dist/esm/internal/transport.js.map +1 -0
- package/dist/esm/package.json +4 -0
- package/dist/esm/store.js +249 -0
- package/dist/esm/store.js.map +1 -0
- package/package.json +35 -0
- package/src/clients.ts +69 -0
- package/src/config.ts +92 -0
- package/src/ensureSchema.ts +46 -0
- package/src/index.ts +45 -0
- package/src/internal/ddl.ts +599 -0
- package/src/internal/matchSql.ts +219 -0
- package/src/internal/readPlan.ts +117 -0
- package/src/internal/transport.ts +141 -0
- package/src/store.ts +413 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration for a Postgres `DcbEventStore`.
|
|
3
|
+
*
|
|
4
|
+
* A store is defined entirely by its `{ schema, tablePrefix }` naming pair plus
|
|
5
|
+
* a `lockTimeout`. Everything else — table/index/function names, the NOTIFY
|
|
6
|
+
* channel, the lock scope — is DERIVED from these (see `internal/ddl.ts`), so
|
|
7
|
+
* one config value is one completely isolated store. There is deliberately NO
|
|
8
|
+
* lockless option here: the exclusive-lock serialisation is not negotiable on the
|
|
9
|
+
* production surface — the lockless variant is a test-fixture-only seam, living in
|
|
10
|
+
* an internal module reachable only from the test graph.
|
|
11
|
+
*
|
|
12
|
+
* The config is a `Schema`, not a bare interface, so that malformed input fails
|
|
13
|
+
* LOUDLY at construction (`layer`/`ensureSchema` decode through it) rather than
|
|
14
|
+
* late and obscurely on the first DDL or append — the same fail-at-the-boundary
|
|
15
|
+
* discipline core applies to `Tag`/`ReadLimit`. It rejects: an empty or dotted
|
|
16
|
+
* `schema`/`tablePrefix` atom (a dot would break the qualified-name dot-split),
|
|
17
|
+
* a negative/non-finite `lockTimeout`, and — via `oversizedIdentifier` — any
|
|
18
|
+
* `{ schema, tablePrefix }` whose derived names would exceed Postgres's 63-byte
|
|
19
|
+
* identifier limit and be silently truncated.
|
|
20
|
+
*/
|
|
21
|
+
import { subscriptionTuningFields } from '@kairos-es/core'
|
|
22
|
+
import { Schema } from 'effect'
|
|
23
|
+
import { oversizedIdentifier } from './internal/ddl'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A single Postgres identifier atom: non-empty and dot-free. Dots are barred
|
|
27
|
+
* because `schema`/`tablePrefix` are composed into qualified names that
|
|
28
|
+
* `sql(identifier)` dot-splits, so an embedded dot would silently re-partition
|
|
29
|
+
* the name into the wrong schema/object.
|
|
30
|
+
*/
|
|
31
|
+
const IdentifierAtom = Schema.String.pipe(
|
|
32
|
+
Schema.nonEmptyString(),
|
|
33
|
+
Schema.pattern(/^[^.]+$/, {
|
|
34
|
+
identifier: 'PostgresIdentifierAtom',
|
|
35
|
+
description: 'a non-empty identifier atom containing no "."',
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Configuration for a Postgres store instance.
|
|
41
|
+
*
|
|
42
|
+
* - `schema` — the Postgres schema that owns the store's objects (default
|
|
43
|
+
* `public`). `ensureSchema` creates it defensively.
|
|
44
|
+
* - `tablePrefix` — the base name for the store's tables/indexes/functions
|
|
45
|
+
* (default `dcb_events`). Two stores with different prefixes (or schemas)
|
|
46
|
+
* cohabit one database without interference.
|
|
47
|
+
* - `lockTimeout` — seconds passed to `set_config('lock_timeout', …, true)`
|
|
48
|
+
* inside every append, bounding how long a blocked writer waits on the
|
|
49
|
+
* exclusive lock. `0` (the default) means wait indefinitely — parity with the
|
|
50
|
+
* in-memory semaphore's unbounded wait, so the shared contract suite behaves
|
|
51
|
+
* identically across engines. A non-zero timeout that fires (`55P03`) is a
|
|
52
|
+
* Postgres-only outcome surfaced OFF the shared `append` contract (a defect),
|
|
53
|
+
* never as `AppendConditionFailed` — the in-memory oracle cannot produce it.
|
|
54
|
+
* - `pollInterval` — MILLISECONDS a live `subscribe` waits for a NOTIFY wake
|
|
55
|
+
* before re-reading from its cursor anyway, defaulting to core's
|
|
56
|
+
* `DEFAULT_POLL_INTERVAL_MILLIS`. Because this engine HAS a wake source, the
|
|
57
|
+
* value bounds worst-case live-tail latency rather than being it — only a
|
|
58
|
+
* subscriber no NOTIFY reaches waits the whole interval; lower it (e.g. in tests)
|
|
59
|
+
* to poll faster. See `packages/core/src/SubscribeMachine.ts` for what the poll
|
|
60
|
+
* guarantees and what a wake source is for.
|
|
61
|
+
* - `catchUpPageSize` — the `subscribe` reconcile page size, defaulting to core's
|
|
62
|
+
* `DEFAULT_CATCH_UP_PAGE_SIZE`: how many events a single catch-up/live read
|
|
63
|
+
* fetches before looping.
|
|
64
|
+
*/
|
|
65
|
+
export const PostgresStoreConfig = Schema.Struct({
|
|
66
|
+
// `exact: true` keeps the decoded type at `key?: T` (never `T | undefined`),
|
|
67
|
+
// matching the repo's `exactOptionalPropertyTypes` discipline — an absent key,
|
|
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 },
|
|
74
|
+
),
|
|
75
|
+
// The two subscribe knobs are core's field definitions, not this engine's: see
|
|
76
|
+
// `subscriptionTuningFields`.
|
|
77
|
+
...subscriptionTuningFields,
|
|
78
|
+
}).pipe(
|
|
79
|
+
// Cross-field guard: the derived table/index/function/channel names must all
|
|
80
|
+
// 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),
|
|
83
|
+
)
|
|
84
|
+
export type PostgresStoreConfig = typeof PostgresStoreConfig.Type
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decode an unknown value into a validated `PostgresStoreConfig`, throwing a
|
|
88
|
+
* `ParseError` on any violation. Used by `layer`/`ensureSchema` so a bad config
|
|
89
|
+
* fails at construction time.
|
|
90
|
+
*/
|
|
91
|
+
export const decodePostgresStoreConfig =
|
|
92
|
+
Schema.decodeUnknownSync(PostgresStoreConfig)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ensureSchema` — the idempotent DDL migration building block (ADR-0002).
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately NOT a migrator. It is a single `Effect<void, SqlError,
|
|
5
|
+
* SqlClient>` that creates (or refreshes) every object a store owns: both
|
|
6
|
+
* tables, both indexes, and both plpgsql functions. It requires the GENERIC
|
|
7
|
+
* `SqlClient` tag — NOT `PgClientPooled` — precisely so a consumer can drop it
|
|
8
|
+
* straight into THEIR OWN migrator, e.g.:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* Migrator.fromRecord({
|
|
12
|
+
* '0001_kairos_store_dcb_events': ensureSchema(config),
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* or simply run it once at boot. We ship no `FileSystem`/`Path`/`ChildProcess`
|
|
17
|
+
* dependency and no `PgMigrator`; migration ORCHESTRATION belongs to the
|
|
18
|
+
* consumer, this package only provides the idempotent PAYLOAD.
|
|
19
|
+
*
|
|
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
|
|
22
|
+
* `sql(identifier)` (via the pre-quoted builder in `internal/ddl.ts`); the DDL
|
|
23
|
+
* bodies carry no bound values (pure schema), and the append functions keep
|
|
24
|
+
* their runtime VALUES as bound parameters, never interpolated.
|
|
25
|
+
*/
|
|
26
|
+
import { SqlClient, type SqlError } from '@effect/sql'
|
|
27
|
+
import { Effect } from 'effect'
|
|
28
|
+
import { decodePostgresStoreConfig, type PostgresStoreConfig } from './config'
|
|
29
|
+
import { resolveNames, runDdl } from './internal/ddl'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Produce the migration payload for `config`. Resolves the object names once and
|
|
33
|
+
* runs the ordered idempotent DDL against whatever `SqlClient` the caller's
|
|
34
|
+
* migrator (or boot code) provides.
|
|
35
|
+
*/
|
|
36
|
+
export const ensureSchema = (
|
|
37
|
+
config: PostgresStoreConfig,
|
|
38
|
+
): Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient> =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const sql = yield* SqlClient.SqlClient
|
|
41
|
+
// Decode through the config schema (same guard as `layer`) so a malformed
|
|
42
|
+
// config fails at construction rather than mid-migration.
|
|
43
|
+
const names = resolveNames(decodePostgresStoreConfig(config))
|
|
44
|
+
// Sequentially, in dependency order (tables → indexes → FK → functions).
|
|
45
|
+
yield* Effect.all(runDdl(sql, names), { discard: true })
|
|
46
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @kairos-es/store-postgres — the Postgres `DcbEventStore` backend.
|
|
3
|
+
*
|
|
4
|
+
* A single store is one `{ schema, tablePrefix }` naming pair (`PostgresStoreConfig`);
|
|
5
|
+
* every object it owns is derived from that pair, so multiple isolated stores
|
|
6
|
+
* cohabit one database. The engine is the two-table B-tree + exclusive-lock
|
|
7
|
+
* conditional append of ADR-0002, with JSON transport and a two-client Layer to
|
|
8
|
+
* survive Neon's pooled LISTEN/NOTIFY drop.
|
|
9
|
+
*
|
|
10
|
+
* Wiring, end to end:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { PgClient } from '@effect/sql-pg'
|
|
14
|
+
* import {
|
|
15
|
+
* ensureSchema,
|
|
16
|
+
* layer,
|
|
17
|
+
* pooledLayer,
|
|
18
|
+
* directLayer,
|
|
19
|
+
* } from '@kairos-es/store-postgres'
|
|
20
|
+
*
|
|
21
|
+
* const config = { schema: 'public', tablePrefix: 'dcb_events' }
|
|
22
|
+
*
|
|
23
|
+
* // 1. Register `ensureSchema(config)` in YOUR migrator (or run it at boot).
|
|
24
|
+
* // 2. Provide the two clients (same URL off Neon; a direct endpoint for
|
|
25
|
+
* // `directLayer` on Neon), then the store layer over them.
|
|
26
|
+
* const clients = Layer.merge(
|
|
27
|
+
* pooledLayer({ url: Redacted.make(poolUrl) }),
|
|
28
|
+
* directLayer({ url: Redacted.make(directUrl) }),
|
|
29
|
+
* )
|
|
30
|
+
* const store = layer(config).pipe(Layer.provide(clients))
|
|
31
|
+
* ```
|
|
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.
|
|
36
|
+
*/
|
|
37
|
+
export {
|
|
38
|
+
directLayer,
|
|
39
|
+
PgClientDirect,
|
|
40
|
+
PgClientPooled,
|
|
41
|
+
pooledLayer,
|
|
42
|
+
} from './clients'
|
|
43
|
+
export type { PostgresStoreConfig } from './config'
|
|
44
|
+
export { ensureSchema } from './ensureSchema'
|
|
45
|
+
export { layer } from './store'
|