@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
|
@@ -0,0 +1,35 @@
|
|
|
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 { type PostgresStoreConfig } from './config.js';
|
|
29
|
+
/**
|
|
30
|
+
* Produce the migration payload for `config`. Resolves the object names once and
|
|
31
|
+
* runs the ordered idempotent DDL against whatever `SqlClient` the caller's
|
|
32
|
+
* migrator (or boot code) provides.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ensureSchema: (config: PostgresStoreConfig) => Effect.Effect<void, SqlError.SqlError, SqlClient.SqlClient>;
|
|
35
|
+
//# sourceMappingURL=ensureSchema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ensureSchema.d.ts","sourceRoot":"","sources":["../../src/ensureSchema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;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"}
|
|
@@ -0,0 +1,41 @@
|
|
|
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 { directLayer, PgClientDirect, PgClientPooled, pooledLayer, } from './clients.js';
|
|
38
|
+
export type { PostgresStoreConfig } from './config.js';
|
|
39
|
+
export { ensureSchema } from './ensureSchema.js';
|
|
40
|
+
export { layer } from './store.js';
|
|
41
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal DDL and object-naming for the Postgres `DcbEventStore` backend.
|
|
3
|
+
*
|
|
4
|
+
* This module is the single source of truth for every object name a store owns
|
|
5
|
+
* and for the plpgsql function bodies. It is deliberately factored apart from
|
|
6
|
+
* the public engine so that:
|
|
7
|
+
*
|
|
8
|
+
* 1. names are derived ONCE, from `{ schema, tablePrefix }`, and reused by both
|
|
9
|
+
* `ensureSchema` (DDL) and the runtime (`append`/`read`/`subscribe`), so the
|
|
10
|
+
* migration and the queries can never disagree about what a table is called;
|
|
11
|
+
* 2. the append-function body is assembled from COMPOSABLE parts — in
|
|
12
|
+
* particular the `LOCK TABLE … IN EXCLUSIVE MODE` clause is an omittable
|
|
13
|
+
* fragment — so a test can `CREATE OR REPLACE` a deliberately-LOCKLESS twin of
|
|
14
|
+
* the append function to prove the shared contract suite actually detects a
|
|
15
|
+
* non-serialising store. The production Layer never exposes a lockless option;
|
|
16
|
+
* the seam lives here, in an internal module, reachable only from the test
|
|
17
|
+
* graph.
|
|
18
|
+
*
|
|
19
|
+
* WHY names are never string-concatenated: every identifier flows through
|
|
20
|
+
* `sql(identifier)`, which quotes and dot-splits safely, so a hostile or merely
|
|
21
|
+
* awkward `schema`/`tablePrefix` cannot inject SQL or collide with a reserved
|
|
22
|
+
* word. The plpgsql *bodies*, which must embed the resolved names as literal
|
|
23
|
+
* text inside a `CREATE FUNCTION … $$ … $$` string, use the SAME quoting via a
|
|
24
|
+
* small `quoteQualified` helper so the body text matches what `sql(identifier)`
|
|
25
|
+
* would emit; VALUES stay bound parameters (`sql.unsafe` with `params`) and are
|
|
26
|
+
* never interpolated.
|
|
27
|
+
*/
|
|
28
|
+
import type { SqlClient, Statement } from '@effect/sql';
|
|
29
|
+
/**
|
|
30
|
+
* A store is one physical `{ schema, tablePrefix }` pair. Every object it owns
|
|
31
|
+
* (both tables, both indexes, both functions, and the NOTIFY channel) is derived
|
|
32
|
+
* from this pair, so one prefix is one completely isolated store: its own
|
|
33
|
+
* bigserial sequence, its own exclusive-lock scope, its own wake channel.
|
|
34
|
+
*/
|
|
35
|
+
export interface ResolvedNames {
|
|
36
|
+
readonly schema: string;
|
|
37
|
+
readonly tablePrefix: string;
|
|
38
|
+
/** `<schema>.<prefix>` — the main event table. */
|
|
39
|
+
readonly mainTable: string;
|
|
40
|
+
/** `<schema>.<prefix>_tags` — the junction tag table. */
|
|
41
|
+
readonly tagTable: string;
|
|
42
|
+
/** `<prefix>_idx_id_type` — the covering unique index on the main table. */
|
|
43
|
+
readonly mainIndex: string;
|
|
44
|
+
/** `<prefix>_idx_tag_main_id` — the composite B-tree on the tag table. */
|
|
45
|
+
readonly tagIndex: string;
|
|
46
|
+
/** `<schema>.<prefix>_append` — the conditional (lock → check → insert → notify) function. */
|
|
47
|
+
readonly appendFn: string;
|
|
48
|
+
/**
|
|
49
|
+
* `<schema>.<prefix>_append_unconditional` — the check-free insert twin. It takes
|
|
50
|
+
* the SAME exclusive lock as the conditional one, which is what discharges the
|
|
51
|
+
* subscribe machine's ascending-commit-order precondition on this engine;
|
|
52
|
+
* `unconditionalAppendBody` owns why a lock-free twin would not.
|
|
53
|
+
*/
|
|
54
|
+
readonly appendUnconditionalFn: string;
|
|
55
|
+
/**
|
|
56
|
+
* The `LISTEN`/`NOTIFY` channel: the fully-qualified events-table name with
|
|
57
|
+
* dots replaced by `_`. A channel is a single unqualified identifier, so the
|
|
58
|
+
* schema is folded into the name to keep two stores in different schemas but
|
|
59
|
+
* with the same prefix from sharing a channel.
|
|
60
|
+
*/
|
|
61
|
+
readonly channel: string;
|
|
62
|
+
}
|
|
63
|
+
/** Default schema when the config omits one. */
|
|
64
|
+
export declare const DEFAULT_SCHEMA = "public";
|
|
65
|
+
/** Default table prefix when the config omits one. */
|
|
66
|
+
export declare const DEFAULT_TABLE_PREFIX = "dcb_events";
|
|
67
|
+
/**
|
|
68
|
+
* The first derived identifier that exceeds 63 bytes, as a human-readable
|
|
69
|
+
* message, or `undefined` when every name fits. Shared by `resolveNames` (which
|
|
70
|
+
* throws) and the `PostgresStoreConfig` schema filter (which fails validation),
|
|
71
|
+
* so the byte bound is enforced identically at both the name-derivation and the
|
|
72
|
+
* config-decode boundary.
|
|
73
|
+
*/
|
|
74
|
+
export declare const oversizedIdentifier: (config: {
|
|
75
|
+
readonly schema?: string;
|
|
76
|
+
readonly tablePrefix?: string;
|
|
77
|
+
}) => string | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Derive every owned object name from `{ schema, tablePrefix }`. The index and
|
|
80
|
+
* function *base* names are unqualified prefixes (`<prefix>_…`) because an index
|
|
81
|
+
* name is scoped to its table's schema and a function is created in `<schema>`;
|
|
82
|
+
* the tables and functions carry the schema explicitly so `sql(identifier)`
|
|
83
|
+
* dot-splits them.
|
|
84
|
+
*
|
|
85
|
+
* Throws at construction if any derived identifier would be silently truncated
|
|
86
|
+
* (see `oversizedIdentifier` / `MAX_IDENTIFIER_BYTES`) — a loud failure at the
|
|
87
|
+
* single choke point beats an invisible index-skip or channel collision later.
|
|
88
|
+
*/
|
|
89
|
+
export declare const resolveNames: (config: {
|
|
90
|
+
readonly schema?: string;
|
|
91
|
+
readonly tablePrefix?: string;
|
|
92
|
+
}) => ResolvedNames;
|
|
93
|
+
/**
|
|
94
|
+
* Quote a possibly-qualified identifier for embedding inside a plpgsql body as
|
|
95
|
+
* literal text. Splits on `.` and double-quotes each atom, mirroring what
|
|
96
|
+
* `sql(identifier)` emits, so the body's table references and the runtime's
|
|
97
|
+
* `sql(identifier)` references resolve to exactly the same object. Any embedded
|
|
98
|
+
* `"` is doubled per SQL identifier-quoting rules.
|
|
99
|
+
*/
|
|
100
|
+
export declare const quoteQualified: (name: string) => string;
|
|
101
|
+
/**
|
|
102
|
+
* The named body-fragment seams, replacing positional boolean flags — the old
|
|
103
|
+
* `unconditionalAppendBody(names, true, false)` was boolean-blind. Both knobs
|
|
104
|
+
* default to the production configuration (locked + notifying); a test twin
|
|
105
|
+
* overrides one field BY NAME. This is the union of both knobs; each body `Pick`s
|
|
106
|
+
* only the ones it actually varies — the conditional body always notifies, so it
|
|
107
|
+
* never took `notify`.
|
|
108
|
+
*
|
|
109
|
+
* - `lock` — include the `LOCK TABLE … IN EXCLUSIVE MODE` clause (default true).
|
|
110
|
+
* BOTH twins vary it: the test-only lockless twins pass `false` to prove the
|
|
111
|
+
* lock is load-bearing (the conditional path's lockless meta-test and the
|
|
112
|
+
* unconditional commit-order repro).
|
|
113
|
+
* - `notify` — include the `pg_notify` wake (default true). ONLY the unconditional
|
|
114
|
+
* twin varies it: the poll-only delivery test passes `false` to prove the
|
|
115
|
+
* periodic poll, not NOTIFY, is the delivery guarantee.
|
|
116
|
+
*/
|
|
117
|
+
export interface AppendBodyOptions {
|
|
118
|
+
readonly lock?: boolean;
|
|
119
|
+
readonly notify?: boolean;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Which append function to (re)create, plus the body-fragment seams. `kind`
|
|
123
|
+
* selects the conditional (guarded) or unconditional (check-free) twin — fixing
|
|
124
|
+
* the function NAME and SIGNATURE — and the seams default to the production
|
|
125
|
+
* configuration.
|
|
126
|
+
*
|
|
127
|
+
* A DISCRIMINATED union, not `extends AppendBodyOptions`, so the seams a `kind`
|
|
128
|
+
* cannot vary are un-expressible: the conditional twin exposes only `lock` (it
|
|
129
|
+
* always notifies), so `{ kind: 'conditional', notify: false }` is a compile error
|
|
130
|
+
* rather than a silently-ignored dead option.
|
|
131
|
+
*/
|
|
132
|
+
export type AppendFunctionOptions = ({
|
|
133
|
+
readonly kind: 'conditional';
|
|
134
|
+
} & Pick<AppendBodyOptions, 'lock'>) | ({
|
|
135
|
+
readonly kind: 'unconditional';
|
|
136
|
+
} & AppendBodyOptions);
|
|
137
|
+
/**
|
|
138
|
+
* Build ONE complete `CREATE OR REPLACE FUNCTION … RETURNS SETOF bigint LANGUAGE
|
|
139
|
+
* plpgsql AS $kairos$…$kairos$` statement for an append function. This is the
|
|
140
|
+
* single source of the wrapper — its qualified name, argument SIGNATURE, and
|
|
141
|
+
* `$kairos$`-delimited body — consumed by `ddlStatements` (production, both twins)
|
|
142
|
+
* AND by the test installers that `CREATE OR REPLACE` a lockless / notify-
|
|
143
|
+
* suppressed twin over their own prefix.
|
|
144
|
+
*
|
|
145
|
+
* WHY one builder: `ddlStatements` plus three test twins previously hand-restated
|
|
146
|
+
* this wrapper, several with string-INTERPOLATED identifiers this module's own
|
|
147
|
+
* doctrine forbids (`"${schema}"."${prefix}_append"` rather than
|
|
148
|
+
* `quoteQualified(names.appendFn)`). A drift in the SIGNATURE across those copies
|
|
149
|
+
* is a silent footgun: `CREATE OR REPLACE` with a changed argument list creates a
|
|
150
|
+
* new OVERLOAD instead of replacing, leaving the original production function live
|
|
151
|
+
* under test. Single-sourcing name + signature makes a twin unable to diverge —
|
|
152
|
+
* a twin `CREATE OR REPLACE`s exactly the function `ddlStatements` created.
|
|
153
|
+
*/
|
|
154
|
+
export declare const appendFunctionStatement: (names: ResolvedNames, options: AppendFunctionOptions) => string;
|
|
155
|
+
/**
|
|
156
|
+
* The full ordered list of idempotent DDL statements that create/refresh a
|
|
157
|
+
* store's objects. Each entry is a `sql.unsafe` statement string; VALUES are not
|
|
158
|
+
* involved (pure DDL), and every identifier is pre-quoted via `quoteQualified`
|
|
159
|
+
* to match the runtime's `sql(identifier)` resolution.
|
|
160
|
+
*
|
|
161
|
+
* Ordering matters: tables before their indexes and before the tag table's FK,
|
|
162
|
+
* functions last (they reference the tables). Everything is `IF NOT EXISTS` /
|
|
163
|
+
* `CREATE OR REPLACE`, so re-running is a no-op — the migration payload is safe
|
|
164
|
+
* to register in any migrator or to run directly.
|
|
165
|
+
*/
|
|
166
|
+
export declare const ddlStatements: (names: ResolvedNames) => ReadonlyArray<string>;
|
|
167
|
+
/**
|
|
168
|
+
* Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
|
|
169
|
+
* public `ensureSchema`) so the same builder feeds both the public migration
|
|
170
|
+
* payload and any internal test setup. Each statement is a separate `sql.unsafe`
|
|
171
|
+
* call so a driver that rejects multi-statement strings still works.
|
|
172
|
+
*/
|
|
173
|
+
export declare const runDdl: (sql: SqlClient.SqlClient, names: ResolvedNames) => ReadonlyArray<Statement.Statement<unknown>>;
|
|
174
|
+
//# sourceMappingURL=ddl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ddl.d.ts","sourceRoot":"","sources":["../../../src/internal/ddl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAQvD;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,kDAAkD;IAClD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,8FAA8F;IAC9F,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,qBAAqB,EAAE,MAAM,CAAA;IACtC;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACzB;AAED,gDAAgD;AAChD,eAAO,MAAM,cAAc,WAAW,CAAA;AACtC,sDAAsD;AACtD,eAAO,MAAM,oBAAoB,eAAe,CAAA;AAuChD;;;;;;GAMG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ;IAC1C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B,KAAG,MAAM,GAAG,SAQZ,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,YAAY,GAAI,QAAQ;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B,KAAG,aAkBH,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,MAIhC,CAAA;AA2Cd;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAC1B;AAuND;;;;;;;;;;GAUG;AACH,MAAM,MAAM,qBAAqB,GAC7B,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAA;CAAE,GAAG,IAAI,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAC,GACpE,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAA;CAAE,GAAG,iBAAiB,CAAC,CAAA;AAE5D;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,uBAAuB,GAClC,OAAO,aAAa,EACpB,SAAS,qBAAqB,KAC7B,MAqBF,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,GAAI,OAAO,aAAa,KAAG,aAAa,CAAC,MAAM,CA+DxE,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,MAAM,GACjB,KAAK,SAAS,CAAC,SAAS,EACxB,OAAO,aAAa,KACnB,aAAa,CAAC,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,CACmB,CAAA"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the tag-first match SQL.
|
|
3
|
+
*
|
|
4
|
+
* The servable-query matching semantics — the tagged AND-superset match with
|
|
5
|
+
* distinct-tag counting, the allowed-types filter, the exclusive `after` bound —
|
|
6
|
+
* were hand-duplicated between the TS read path (`store.ts`) and the plpgsql
|
|
7
|
+
* conflict check (`ddl.ts`): two SQL dialect-copies, in two files, that drift
|
|
8
|
+
* independently (the read's wildcard mis-routing of a degenerate empty item was
|
|
9
|
+
* one such drift). This module emits the shared fragments ONCE, parameterised by:
|
|
10
|
+
*
|
|
11
|
+
* - the PLACEHOLDER TOKENS each call site binds — plpgsql parameter names
|
|
12
|
+
* (`query_items`, `COALESCE(after_id, 0)`) for the conflict body vs bound
|
|
13
|
+
* `$n` placeholders for the runtime `sql.unsafe(text, params)` read; and
|
|
14
|
+
* - the PROJECTION — full rows (read) vs an `EXISTS`/`LIMIT 1` probe (conflict),
|
|
15
|
+
* which each call site wraps around the shared `qualifiedRows` source.
|
|
16
|
+
*
|
|
17
|
+
* The `jsonb_to_recordset` event column list also lives here as ONE descriptor,
|
|
18
|
+
* so the write recordset (`ddl.ts`) and the read projection cannot disagree.
|
|
19
|
+
*
|
|
20
|
+
* Table names arrive PRE-QUOTED (via `quoteQualified` in `ddl.ts`), so this
|
|
21
|
+
* module never quotes identifiers itself and stays free of the naming logic.
|
|
22
|
+
* Every fragment assumes the main events table is aliased `m`.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The event payload columns shared by the write recordset and every read
|
|
26
|
+
* projection, as `[name, recordsetType]` pairs. `id` (the `bigserial` position)
|
|
27
|
+
* is prepended by the read projection and absent from the events recordset.
|
|
28
|
+
*
|
|
29
|
+
* `as const` freezes it into a tuple of literal names so `EventColumnName` (below)
|
|
30
|
+
* can derive the exact column-name union — the whole point of the "one descriptor"
|
|
31
|
+
* story: the TS transport shapes in `internal/transport.ts` are mapped types over
|
|
32
|
+
* that union, so a column rename here is a COMPILE error there, not a silent drift
|
|
33
|
+
* the lax read boundary (ADR-0006) would only surface as corrupt data under test.
|
|
34
|
+
*/
|
|
35
|
+
declare const EVENT_COLUMNS: readonly [readonly ["type", "text"], readonly ["data", "text"], readonly ["tags", "text[]"], readonly ["uuid", "text"], readonly ["occurred_at", "timestamptz"]];
|
|
36
|
+
/**
|
|
37
|
+
* The event payload column NAMES as a string-literal union, derived from the ONE
|
|
38
|
+
* `EVENT_COLUMNS` descriptor. `internal/transport.ts` builds its transport shapes
|
|
39
|
+
* (`EventJson`, the JSON sent to the append functions; `EventRow`, a raw driver
|
|
40
|
+
* row) as mapped types over this union, so the transport shapes cannot drift from
|
|
41
|
+
* the SQL recordset/projection — a rename in the descriptor removes the old key
|
|
42
|
+
* from the union and makes the mapped type fail to compile.
|
|
43
|
+
*/
|
|
44
|
+
export type EventColumnName = (typeof EVENT_COLUMNS)[number][0];
|
|
45
|
+
/**
|
|
46
|
+
* The `jsonb_to_recordset(new_events) AS (…)` column definitions —
|
|
47
|
+
* `type text, data text, tags text[], uuid text, occurred_at timestamptz`. The
|
|
48
|
+
* transport `data` is base64 TEXT (restored to `bytea` by `decode(…, 'base64')`
|
|
49
|
+
* at insert), which is why it is `text` here, not `bytea`.
|
|
50
|
+
*/
|
|
51
|
+
export declare const eventRecordsetColumns: string;
|
|
52
|
+
/**
|
|
53
|
+
* The read projection column list (`id` + the event columns), optionally
|
|
54
|
+
* prefixed with a table alias (`'m.'`). Sourced from the one descriptor so it
|
|
55
|
+
* cannot drift from the recordset.
|
|
56
|
+
*/
|
|
57
|
+
export declare const readColumns: (prefix?: string) => string;
|
|
58
|
+
/**
|
|
59
|
+
* The allowed-types filter for a candidate row aliased `m`: an item with no
|
|
60
|
+
* types matches ANY type; otherwise the event's type must be one of them.
|
|
61
|
+
* `typesCol` is the `text[]` column holding the item's types — `q.allowed_types`
|
|
62
|
+
* for a tagged match, `qi.types` for a tagless one.
|
|
63
|
+
*/
|
|
64
|
+
export declare const allowedTypesPredicate: (typesCol: string) => string;
|
|
65
|
+
/** The placeholder tokens + pre-quoted table names one call site binds. */
|
|
66
|
+
export interface MatchTokens {
|
|
67
|
+
/** SQL yielding the query-items jsonb: `query_items` or `$1::jsonb`. */
|
|
68
|
+
readonly queryItemsExpr: string;
|
|
69
|
+
/** SQL for the exclusive lower bound: `COALESCE(after_id, 0)` or `$2`. */
|
|
70
|
+
readonly afterExpr: string;
|
|
71
|
+
/** Pre-quoted main events table. */
|
|
72
|
+
readonly main: string;
|
|
73
|
+
/** Pre-quoted tag junction table. */
|
|
74
|
+
readonly tag: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The tag-first match CTE chain shared by the read and conflict paths: expand
|
|
78
|
+
* the query items (`qi`), join the tag junction, count DISTINCT matched tags per
|
|
79
|
+
* (event, item), and keep events matching an item's FULL tag superset. Returns
|
|
80
|
+
* `qi, initial_matches, matched_groups, qualified_ids` — the caller follows it
|
|
81
|
+
* with a projection CTE selecting from `qualified_ids`.
|
|
82
|
+
*
|
|
83
|
+
* ## Both sides of the count are SET SIZES, and that is the whole invariant
|
|
84
|
+
*
|
|
85
|
+
* `qualified_ids` compares two numbers, and the ONE thing an edit here must
|
|
86
|
+
* preserve is that they count the same kind of thing: `matched_tag_count` is the
|
|
87
|
+
* number of DISTINCT tags the event carried out of the item's requirement, so
|
|
88
|
+
* `required_tag_count` is the size of that requirement AS A SET and never the
|
|
89
|
+
* length of the list it arrived as. A query item may list one tag twice — no
|
|
90
|
+
* constructor de-duplicates, `assertServableQuery` admits it and
|
|
91
|
+
* `classifyServableQuery` routes it here — and the in-memory oracle folds an
|
|
92
|
+
* item's tags into a superset test that a repeat cannot change. Comparing a
|
|
93
|
+
* de-duplicated 1 against a list length of 2 refused such a match: the READ
|
|
94
|
+
* returned nothing and, because the plpgsql conflict body composes this same
|
|
95
|
+
* chain, the GUARD found no conflict and the append it should have refused
|
|
96
|
+
* succeeded — an optimistic-concurrency check that escaped silently.
|
|
97
|
+
*
|
|
98
|
+
* The asymmetry has two repairs — de-duplicate both sides, or expand both — and
|
|
99
|
+
* only the first is available here. The matched side CANNOT become a plain row
|
|
100
|
+
* count, because this engine's junction holds one row per (tag, event) OCCURRENCE
|
|
101
|
+
* and not per pair, so an event whose own `tags` list repeats a tag has two rows
|
|
102
|
+
* there and a row count would refuse a single-tag item against it. `ddl.ts` states
|
|
103
|
+
* that property on the junction, and it is what `COUNT(DISTINCT tag)` is here to
|
|
104
|
+
* absorb; the requirement is de-duplicated to meet it.
|
|
105
|
+
*
|
|
106
|
+
* `required_tag_count` is therefore computed in `qi`, once per ITEM, rather than
|
|
107
|
+
* in the group that consumes it — the chain groups per (event, item), so a
|
|
108
|
+
* subquery in `matched_groups` would be re-evaluated per matched event. It is
|
|
109
|
+
* `count(*)` over a `SELECT DISTINCT` rather than `count(DISTINCT …)` because the
|
|
110
|
+
* latter drops a NULL element: a `[tag, NULL]` requirement would shrink to 1,
|
|
111
|
+
* meet a single matched tag and match, where the oracle's superset test cannot be
|
|
112
|
+
* satisfied by a NULL and refuses it.
|
|
113
|
+
*
|
|
114
|
+
* The `array_length(qi.tags, 1) >= 1` guard excludes tagless items from the
|
|
115
|
+
* tagged branch (load-bearing for the conflict body, which also has a separate
|
|
116
|
+
* tagless branch; a no-op for the read, whose tagged shape is all-tagged). It
|
|
117
|
+
* reads the item's own array because it is a test of the item's SHAPE — tagged or
|
|
118
|
+
* tagless — and not part of the match arithmetic above.
|
|
119
|
+
*/
|
|
120
|
+
export declare const taggedMatchChain: (tokens: MatchTokens) => string;
|
|
121
|
+
/**
|
|
122
|
+
* The shared qualified-rows source: join `qualified_ids` back to the main table
|
|
123
|
+
* (aliased `m`), re-apply the exclusive `after` bound, and filter by allowed
|
|
124
|
+
* types. The read wraps this in an ordered/limited `DISTINCT m.id` CTE; the
|
|
125
|
+
* conflict wraps it in a `SELECT 1 … LIMIT 1` existence probe.
|
|
126
|
+
*/
|
|
127
|
+
export declare const qualifiedRows: (tokens: Pick<MatchTokens, "main" | "afterExpr">) => string;
|
|
128
|
+
/** The wildcard read (no items): every event after the bound, ordered/limited. */
|
|
129
|
+
export declare const wildcardReadSql: (main: string) => string;
|
|
130
|
+
/** The type-only read (single tagless item): filter by `type = ANY($2)`. */
|
|
131
|
+
export declare const typeOnlyReadSql: (main: string) => string;
|
|
132
|
+
/**
|
|
133
|
+
* The tagged read: the shared match chain, then a `filtered_ids` CTE that orders
|
|
134
|
+
* and limits the qualified ids by position, then a full-row select. Bound params
|
|
135
|
+
* are `$1` = query-items jsonb, `$2` = exclusive `after`, `$3` = limit (`NULL` =
|
|
136
|
+
* no limit).
|
|
137
|
+
*/
|
|
138
|
+
export declare const taggedReadSql: (main: string, tag: string) => string;
|
|
139
|
+
export {};
|
|
140
|
+
//# sourceMappingURL=matchSql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matchSql.d.ts","sourceRoot":"","sources":["../../../src/internal/matchSql.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH;;;;;;;;;;GAUG;AACH,QAAA,MAAM,aAAa,kKAQlB,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAA;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAExB,CAAA;AAEZ;;;;GAIG;AACH,eAAO,MAAM,WAAW,GAAI,eAAW,KAAG,MAGvC,CAAA;AAEH;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAAI,UAAU,MAAM,KAAG,MACkD,CAAA;AAE3G,2EAA2E;AAC3E,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qCAAqC;IACrC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,eAAO,MAAM,gBAAgB,GAAI,QAAQ,WAAW,KAAG,MA6BnD,CAAA;AAEJ;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GACxB,QAAQ,IAAI,CAAC,WAAW,EAAE,MAAM,GAAG,WAAW,CAAC,KAC9C,MAImD,CAAA;AAEtD,kFAAkF;AAClF,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MAKnC,CAAA;AAEZ,4EAA4E;AAC5E,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MAKnC,CAAA;AAEZ;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,MAAM,EAAE,KAAK,MAAM,KAAG,MAWtC,CAAA"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read-plan compiler for the Postgres engine, extracted from `store.ts` so it
|
|
3
|
+
* is a self-contained unit depending only on the PRE-QUOTED table names — never on
|
|
4
|
+
* the SQL clients. `store.ts`'s `read` and its `subscribe` — the latter feeding the
|
|
5
|
+
* plan to core's subscribe machine as that machine's `fetch` seam — both compile a
|
|
6
|
+
* plan here and run it against whichever client they hold.
|
|
7
|
+
*
|
|
8
|
+
* `compileFetchPlan` turns a servable `Query` into prepared SQL text plus a
|
|
9
|
+
* cursor-only `bind`, so a long-lived subscription re-binds only the cursor rather
|
|
10
|
+
* than re-dispatching the shape and re-serialising the query JSON on every poll.
|
|
11
|
+
* Which shape a query IS is not decided here: core's `classifyServableQuery` owns
|
|
12
|
+
* that dispatch beside the grammar it dispatches over (`Query.ts`), including the
|
|
13
|
+
* degenerate empty item's routing. The JSON/JSONB transport shapes and
|
|
14
|
+
* (de)serialisers — `EventRow`, `jsonbText`, `toQueryItemsJson`, `rowToSequenced`
|
|
15
|
+
* — live in `internal/transport.ts`, and this module only compiles plans.
|
|
16
|
+
*
|
|
17
|
+
* A plan compiled here is therefore the one place the Postgres engine dispatches
|
|
18
|
+
* a shape in TypeScript, and the exhaustiveness below protects BOTH verbs that
|
|
19
|
+
* compile one — `read`, and the `subscribe` poll sharing its plan. What it cannot
|
|
20
|
+
* reach is the append: its conflict guard covers the same three shapes inside its
|
|
21
|
+
* plpgsql body, where no compiler follows, and `ddl.ts` owns what that rests on
|
|
22
|
+
* instead.
|
|
23
|
+
*/
|
|
24
|
+
import type { SqlClient, SqlError } from '@effect/sql';
|
|
25
|
+
import type { Position, ReadLimit } from '@kairos-es/core';
|
|
26
|
+
import { type Query } from '@kairos-es/core';
|
|
27
|
+
import type { Effect } from 'effect';
|
|
28
|
+
import { type EventRow } from './transport.js';
|
|
29
|
+
/** The pre-quoted table names a plan reads from (see `quoteQualified`). */
|
|
30
|
+
export interface ReadTables {
|
|
31
|
+
/** Pre-quoted main events table. */
|
|
32
|
+
readonly main: string;
|
|
33
|
+
/** Pre-quoted tag junction table. */
|
|
34
|
+
readonly tag: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A COMPILED read plan: the prepared SQL text plus a `bind` that produces the
|
|
38
|
+
* positional params for a given `after`/`limit`. Everything
|
|
39
|
+
* query-dependent-but-cursor-independent — the shape dispatch, the SQL text, the
|
|
40
|
+
* query-items JSON serialisation — is done ONCE, so a long-lived subscription's
|
|
41
|
+
* poll loop re-binds only the cursor rather than re-classifying and re-JSON-
|
|
42
|
+
* serialising on every iteration.
|
|
43
|
+
*/
|
|
44
|
+
export interface FetchPlan {
|
|
45
|
+
readonly text: string;
|
|
46
|
+
readonly bind: (after: Position, limit: ReadLimit | null) => ReadonlyArray<unknown>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Compile a servable query into a `FetchPlan`. The SQL comes from the shared
|
|
50
|
+
* `matchSql` builders (the tagged path composes the same emitted match chain as the
|
|
51
|
+
* conflict check, which binds its own placeholder tokens and wraps it differently),
|
|
52
|
+
* with the pre-quoted names embedded and the VALUES bound as `$n` parameters
|
|
53
|
+
* (never interpolated).
|
|
54
|
+
*
|
|
55
|
+
* The `switch` is exhaustive over core's `ServableQueryShape` with NO `default`
|
|
56
|
+
* and no trailing fall-through arm, and what that buys is a COMPILE error where
|
|
57
|
+
* there would otherwise be a silent mis-serve. Core owns that union and this
|
|
58
|
+
* module only consumes it, so a fourth member added there is a change this file
|
|
59
|
+
* cannot see coming: a cascade ending in an unguarded tagged branch would have
|
|
60
|
+
* SERVED that shape as tagged, and both `read` and the `subscribe` poll sharing
|
|
61
|
+
* its plan would have returned the wrong rows — no error raised, nothing failing
|
|
62
|
+
* to typecheck. Every arm returns instead, so the added member leaves this
|
|
63
|
+
* function with a reachable end and a return type that does not admit
|
|
64
|
+
* `undefined`, and the module stops compiling.
|
|
65
|
+
*/
|
|
66
|
+
export declare const compileFetchPlan: (tables: ReadTables, query: Query) => FetchPlan;
|
|
67
|
+
/**
|
|
68
|
+
* Run a compiled plan for a given `after`/`limit`, honouring `after` (exclusive;
|
|
69
|
+
* absent → `0`) and `limit` (`NULL` = no limit), ordered by `id ASC`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const runFetch: (sql: SqlClient.SqlClient, plan: FetchPlan, after: Position | undefined, limit: ReadLimit | undefined) => Effect.Effect<ReadonlyArray<EventRow>, SqlError.SqlError>;
|
|
72
|
+
//# sourceMappingURL=readPlan.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"readPlan.d.ts","sourceRoot":"","sources":["../../../src/internal/readPlan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AACtD,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAC1D,OAAO,EAAiC,KAAK,KAAK,EAAE,MAAM,iBAAiB,CAAA;AAC3E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEpC,OAAO,EAAE,KAAK,QAAQ,EAA+B,MAAM,aAAa,CAAA;AAExE,2EAA2E;AAC3E,MAAM,WAAW,UAAU;IACzB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qCAAqC;IACrC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,CACb,KAAK,EAAE,QAAQ,EACf,KAAK,EAAE,SAAS,GAAG,IAAI,KACpB,aAAa,CAAC,OAAO,CAAC,CAAA;CAC5B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,UAAU,EAClB,OAAO,KAAK,KACX,SA6BF,CAAA;AAED;;;GAGG;AACH,eAAO,MAAM,QAAQ,GACnB,KAAK,SAAS,CAAC,SAAS,EACxB,MAAM,SAAS,EACf,OAAO,QAAQ,GAAG,SAAS,EAC3B,OAAO,SAAS,GAAG,SAAS,KAC3B,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,QAAQ,CACiB,CAAA"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subscribe machine for the Postgres engine, extracted from `store.ts`. It owns
|
|
3
|
+
* the wake queue, the listener drain, the paged catch-up reconcile, the `> boundary`
|
|
4
|
+
* de-dup (via the cursor's exclusive lower bound), and the poll loop — all behind
|
|
5
|
+
* two STUBBABLE seams (`fetch`, `listen`), so the wake/poll/dedup logic is
|
|
6
|
+
* UNIT-TESTABLE with no live database (see `test/subscribe.test.ts`). `store.ts`
|
|
7
|
+
* supplies production `fetch`/`listen` built from the pooled/direct clients.
|
|
8
|
+
*
|
|
9
|
+
* Correctness rests on the periodic position-based reconcile — a re-read from the
|
|
10
|
+
* cursor every poll interval — NOT on `NOTIFY`, which is only a latency
|
|
11
|
+
* optimisation. Exclusive-lock commit order equals sequence order, so an
|
|
12
|
+
* ascending re-read never skips a temporary gap; permanent bigserial gaps are
|
|
13
|
+
* simply absent positions and are fine to skip.
|
|
14
|
+
*
|
|
15
|
+
* ## `catchUpPageSize` bounds the STATEMENT, not the emission
|
|
16
|
+
*
|
|
17
|
+
* `drainFrom` below PAGES the catch-up read — `catchUpPageSize` bounds each
|
|
18
|
+
* `SELECT` — but it COLLECTS every page into one array, which is emitted as a
|
|
19
|
+
* SINGLE chunk. So peak memory at subscription start is O(backlog) rather than
|
|
20
|
+
* O(page): a subscriber joining behind a large log materialises the whole backlog
|
|
21
|
+
* before its consumer sees the first element.
|
|
22
|
+
*
|
|
23
|
+
* That is a memory characteristic and nothing more, which is why ONE chunk is
|
|
24
|
+
* enough here: downstream commit boundaries do not depend on the upstream chunking
|
|
25
|
+
* at all, because the read side's `Stream.groupedWithin` re-chunks the stream into
|
|
26
|
+
* bounded micro-batches whatever arrives (ADR-0007), so a projection's transactions
|
|
27
|
+
* stay small even against a one-chunk catch-up. The cost therefore lands on backlog
|
|
28
|
+
* memory alone, and it lands hardest on the durable-log-with-in-memory-view mode,
|
|
29
|
+
* where an ephemeral view re-catches-up the entire log on every boot.
|
|
30
|
+
*/
|
|
31
|
+
import type { SqlError } from '@effect/sql';
|
|
32
|
+
import type { Position, SequencedEvent } from '@kairos-es/core';
|
|
33
|
+
import { ReadLimit } from '@kairos-es/core';
|
|
34
|
+
import { Effect, type Scope, Stream } from 'effect';
|
|
35
|
+
/**
|
|
36
|
+
* A short catch-up page size default for the reconcile loop. Reads page by page
|
|
37
|
+
* until a short page signals the tail is drained, so a large backlog is streamed
|
|
38
|
+
* in bounded chunks rather than one huge SELECT. Overridable via
|
|
39
|
+
* `config.catchUpPageSize`.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DEFAULT_CATCH_UP_PAGE = 512;
|
|
42
|
+
/**
|
|
43
|
+
* The default upper bound a live subscriber waits for a wake before re-reading
|
|
44
|
+
* from its cursor ANYWAY (milliseconds; overridable via `config.pollInterval`).
|
|
45
|
+
* This periodic position-based poll — not `NOTIFY` — is the correctness
|
|
46
|
+
* guarantee: every committed position is reconciled within this interval
|
|
47
|
+
* regardless of whether its `NOTIFY` was heard, which covers a `NOTIFY` fired
|
|
48
|
+
* before `LISTEN` was fully established, a coalesced/lost `NOTIFY`, and a listen
|
|
49
|
+
* connection that dropped SILENTLY (the `@effect/sql-pg` listen client swallows
|
|
50
|
+
* connection errors and does not auto-reconnect). `NOTIFY` only lowers delivery
|
|
51
|
+
* latency below this interval. Upstream `postgres_tt.py` uses the same 1-second
|
|
52
|
+
* fallback poll (`conn.wait(..., interval=1)`).
|
|
53
|
+
*/
|
|
54
|
+
export declare const DEFAULT_POLL_INTERVAL_MILLIS = 1000;
|
|
55
|
+
/**
|
|
56
|
+
* The two seams the subscribe machine runs over, plus the two tuning knobs. In
|
|
57
|
+
* production `fetch` is a compiled read plan run against the pooled client and
|
|
58
|
+
* mapped to `SequencedEvent`s, and `listen` is `PgClientDirect.listen(channel)`;
|
|
59
|
+
* a unit test stubs both with plain in-memory values.
|
|
60
|
+
*/
|
|
61
|
+
export interface SubscriptionDeps {
|
|
62
|
+
/**
|
|
63
|
+
* Page-read the events strictly after `from`, up to `limit`, ascending by
|
|
64
|
+
* position. Returning FEWER than `limit` signals the tail is drained.
|
|
65
|
+
*/
|
|
66
|
+
readonly fetch: (from: Position, limit: ReadLimit) => Effect.Effect<ReadonlyArray<SequencedEvent>, SqlError.SqlError>;
|
|
67
|
+
/**
|
|
68
|
+
* The raw wake source: each element is an opaque wake signal (a `NOTIFY`). The
|
|
69
|
+
* machine taps it into its own wake queue and drains it on a background fibre;
|
|
70
|
+
* it never reads the elements. `Stream.never` is a valid poll-only source.
|
|
71
|
+
*/
|
|
72
|
+
readonly listen: Stream.Stream<unknown, SqlError.SqlError>;
|
|
73
|
+
/** The poll interval in milliseconds (see `DEFAULT_POLL_INTERVAL_MILLIS`). */
|
|
74
|
+
readonly pollInterval: number;
|
|
75
|
+
/** The catch-up page size (see `DEFAULT_CATCH_UP_PAGE`). */
|
|
76
|
+
readonly catchUpPage: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the subscription `Stream`: catch up from `after`, then track the live
|
|
80
|
+
* tail. The cursor threads through the `unfoldChunkEffect` unfold state, starting
|
|
81
|
+
* at `after` and advancing to the last delivered position each batch; the stream
|
|
82
|
+
* never completes on its own (always `Option.some`), so the enclosing scope
|
|
83
|
+
* interrupts it on close. A `SqlError` mid-stream stays on the channel;
|
|
84
|
+
* `makeContractStore` dies it (subscribe is `E = never` on the contract).
|
|
85
|
+
*/
|
|
86
|
+
export declare const subscribeStream: (deps: SubscriptionDeps, after: Position | undefined) => Stream.Stream<SequencedEvent, SqlError.SqlError, Scope.Scope>;
|
|
87
|
+
//# sourceMappingURL=subscribe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"subscribe.d.ts","sourceRoot":"","sources":["../../../src/internal/subscribe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAC/D,OAAO,EAAU,SAAS,EAAE,MAAM,iBAAiB,CAAA;AACnD,OAAO,EAEL,MAAM,EAIN,KAAK,KAAK,EACV,MAAM,EACP,MAAM,QAAQ,CAAA;AAEf;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAA;AAExC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,4BAA4B,OAAO,CAAA;AAchD;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,QAAQ,CAAC,KAAK,EAAE,CACd,IAAI,EAAE,QAAQ,EACd,KAAK,EAAE,SAAS,KACb,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,cAAc,CAAC,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAA;IACpE;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAA;IAC1D,8EAA8E;IAC9E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,4DAA4D;IAC5D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAC1B,MAAM,gBAAgB,EACtB,OAAO,QAAQ,GAAG,SAAS,KAC1B,MAAM,CAAC,MAAM,CAAC,cAAc,EAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAuG5D,CAAA"}
|