@kairos-es/store-postgres 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +167 -0
  3. package/dist/cjs/clients.js +62 -0
  4. package/dist/cjs/clients.js.map +1 -0
  5. package/dist/cjs/config.js +94 -0
  6. package/dist/cjs/config.js.map +1 -0
  7. package/dist/cjs/ensureSchema.js +53 -0
  8. package/dist/cjs/ensureSchema.js.map +1 -0
  9. package/dist/cjs/index.js +45 -0
  10. package/dist/cjs/index.js.map +1 -0
  11. package/dist/cjs/internal/ddl.js +436 -0
  12. package/dist/cjs/internal/ddl.js.map +1 -0
  13. package/dist/cjs/internal/matchSql.js +189 -0
  14. package/dist/cjs/internal/matchSql.js.map +1 -0
  15. package/dist/cjs/internal/readPlan.js +67 -0
  16. package/dist/cjs/internal/readPlan.js.map +1 -0
  17. package/dist/cjs/internal/subscribe.js +120 -0
  18. package/dist/cjs/internal/subscribe.js.map +1 -0
  19. package/dist/cjs/internal/transport.js +66 -0
  20. package/dist/cjs/internal/transport.js.map +1 -0
  21. package/dist/cjs/store.js +256 -0
  22. package/dist/cjs/store.js.map +1 -0
  23. package/dist/dts/clients.d.ts +34 -0
  24. package/dist/dts/clients.d.ts.map +1 -0
  25. package/dist/dts/config.d.ts +58 -0
  26. package/dist/dts/config.d.ts.map +1 -0
  27. package/dist/dts/ensureSchema.d.ts +35 -0
  28. package/dist/dts/ensureSchema.d.ts.map +1 -0
  29. package/dist/dts/index.d.ts +41 -0
  30. package/dist/dts/index.d.ts.map +1 -0
  31. package/dist/dts/internal/ddl.d.ts +174 -0
  32. package/dist/dts/internal/ddl.d.ts.map +1 -0
  33. package/dist/dts/internal/matchSql.d.ts +140 -0
  34. package/dist/dts/internal/matchSql.d.ts.map +1 -0
  35. package/dist/dts/internal/readPlan.d.ts +72 -0
  36. package/dist/dts/internal/readPlan.d.ts.map +1 -0
  37. package/dist/dts/internal/subscribe.d.ts +87 -0
  38. package/dist/dts/internal/subscribe.d.ts.map +1 -0
  39. package/dist/dts/internal/transport.d.ts +116 -0
  40. package/dist/dts/internal/transport.d.ts.map +1 -0
  41. package/dist/dts/store.d.ts +17 -0
  42. package/dist/dts/store.d.ts.map +1 -0
  43. package/dist/esm/clients.js +51 -0
  44. package/dist/esm/clients.js.map +1 -0
  45. package/dist/esm/config.js +87 -0
  46. package/dist/esm/config.js.map +1 -0
  47. package/dist/esm/ensureSchema.js +45 -0
  48. package/dist/esm/ensureSchema.js.map +1 -0
  49. package/dist/esm/index.js +40 -0
  50. package/dist/esm/index.js.map +1 -0
  51. package/dist/esm/internal/ddl.js +424 -0
  52. package/dist/esm/internal/ddl.js.map +1 -0
  53. package/dist/esm/internal/matchSql.js +176 -0
  54. package/dist/esm/internal/matchSql.js.map +1 -0
  55. package/dist/esm/internal/readPlan.js +59 -0
  56. package/dist/esm/internal/readPlan.js.map +1 -0
  57. package/dist/esm/internal/subscribe.js +113 -0
  58. package/dist/esm/internal/subscribe.js.map +1 -0
  59. package/dist/esm/internal/transport.js +56 -0
  60. package/dist/esm/internal/transport.js.map +1 -0
  61. package/dist/esm/package.json +4 -0
  62. package/dist/esm/store.js +249 -0
  63. package/dist/esm/store.js.map +1 -0
  64. package/package.json +35 -0
  65. package/src/clients.ts +69 -0
  66. package/src/config.ts +92 -0
  67. package/src/ensureSchema.ts +46 -0
  68. package/src/index.ts +45 -0
  69. package/src/internal/ddl.ts +599 -0
  70. package/src/internal/matchSql.ts +219 -0
  71. package/src/internal/readPlan.ts +117 -0
  72. package/src/internal/transport.ts +141 -0
  73. package/src/store.ts +413 -0
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'