@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,424 @@
|
|
|
1
|
+
import { allowedTypesPredicate, eventRecordsetColumns, qualifiedRows, taggedMatchChain } from "./matchSql.js";
|
|
2
|
+
/** Default schema when the config omits one. */
|
|
3
|
+
export const DEFAULT_SCHEMA = 'public';
|
|
4
|
+
/** Default table prefix when the config omits one. */
|
|
5
|
+
export const DEFAULT_TABLE_PREFIX = 'dcb_events';
|
|
6
|
+
/**
|
|
7
|
+
* Postgres truncates any identifier to 63 bytes (`NAMEDATALEN - 1`) with only a
|
|
8
|
+
* notice. Two configs whose derived names share a 63-byte prefix would then
|
|
9
|
+
* collide silently: `CREATE INDEX IF NOT EXISTS` sees a name-match and SKIPS
|
|
10
|
+
* creating the second store's index (an invisible performance cliff), and two
|
|
11
|
+
* NOTIFY channels truncate into one (cross-store wakes, defeating cohabitation).
|
|
12
|
+
*/
|
|
13
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
14
|
+
/** UTF-8 byte length (identifiers are bytes, not chars, to Postgres). */
|
|
15
|
+
const utf8ByteLength = value => new TextEncoder().encode(value).length;
|
|
16
|
+
/**
|
|
17
|
+
* The single-atom identifiers Postgres actually materialises for a config —
|
|
18
|
+
* every table/index/function base name plus the NOTIFY channel. `schema.prefix`
|
|
19
|
+
* qualified names are two atoms to Postgres (`schema` and the table name), so
|
|
20
|
+
* each atom is measured separately, not the dotted whole.
|
|
21
|
+
*/
|
|
22
|
+
const derivedAtoms = config => {
|
|
23
|
+
const schema = config.schema ?? DEFAULT_SCHEMA;
|
|
24
|
+
const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
|
|
25
|
+
return [['schema', schema], ['events table', tablePrefix], ['tag table', `${tablePrefix}_tags`], ['main index', `${tablePrefix}_idx_id_type`], ['tag index', `${tablePrefix}_idx_tag_main_id`], ['append function', `${tablePrefix}_append`], ['unconditional append function', `${tablePrefix}_append_unconditional`], ['NOTIFY channel', `${schema}_${tablePrefix}`.replace(/\./g, '_')]];
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The first derived identifier that exceeds 63 bytes, as a human-readable
|
|
29
|
+
* message, or `undefined` when every name fits. Shared by `resolveNames` (which
|
|
30
|
+
* throws) and the `PostgresStoreConfig` schema filter (which fails validation),
|
|
31
|
+
* so the byte bound is enforced identically at both the name-derivation and the
|
|
32
|
+
* config-decode boundary.
|
|
33
|
+
*/
|
|
34
|
+
export const oversizedIdentifier = config => {
|
|
35
|
+
for (const [label, atom] of derivedAtoms(config)) {
|
|
36
|
+
const bytes = utf8ByteLength(atom);
|
|
37
|
+
if (bytes > MAX_IDENTIFIER_BYTES) {
|
|
38
|
+
return `the ${label} identifier "${atom}" is ${bytes} bytes, over Postgres's ${MAX_IDENTIFIER_BYTES}-byte limit; it would be silently truncated, risking a cross-store index-skip or NOTIFY-channel collision`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Derive every owned object name from `{ schema, tablePrefix }`. The index and
|
|
45
|
+
* function *base* names are unqualified prefixes (`<prefix>_…`) because an index
|
|
46
|
+
* name is scoped to its table's schema and a function is created in `<schema>`;
|
|
47
|
+
* the tables and functions carry the schema explicitly so `sql(identifier)`
|
|
48
|
+
* dot-splits them.
|
|
49
|
+
*
|
|
50
|
+
* Throws at construction if any derived identifier would be silently truncated
|
|
51
|
+
* (see `oversizedIdentifier` / `MAX_IDENTIFIER_BYTES`) — a loud failure at the
|
|
52
|
+
* single choke point beats an invisible index-skip or channel collision later.
|
|
53
|
+
*/
|
|
54
|
+
export const resolveNames = config => {
|
|
55
|
+
const problem = oversizedIdentifier(config);
|
|
56
|
+
if (problem !== undefined) {
|
|
57
|
+
throw new Error(`resolveNames: ${problem}`);
|
|
58
|
+
}
|
|
59
|
+
const schema = config.schema ?? DEFAULT_SCHEMA;
|
|
60
|
+
const tablePrefix = config.tablePrefix ?? DEFAULT_TABLE_PREFIX;
|
|
61
|
+
return {
|
|
62
|
+
schema,
|
|
63
|
+
tablePrefix,
|
|
64
|
+
mainTable: `${schema}.${tablePrefix}`,
|
|
65
|
+
tagTable: `${schema}.${tablePrefix}_tags`,
|
|
66
|
+
mainIndex: `${tablePrefix}_idx_id_type`,
|
|
67
|
+
tagIndex: `${tablePrefix}_idx_tag_main_id`,
|
|
68
|
+
appendFn: `${schema}.${tablePrefix}_append`,
|
|
69
|
+
appendUnconditionalFn: `${schema}.${tablePrefix}_append_unconditional`,
|
|
70
|
+
channel: `${schema}_${tablePrefix}`.replace(/\./g, '_')
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Quote a possibly-qualified identifier for embedding inside a plpgsql body as
|
|
75
|
+
* literal text. Splits on `.` and double-quotes each atom, mirroring what
|
|
76
|
+
* `sql(identifier)` emits, so the body's table references and the runtime's
|
|
77
|
+
* `sql(identifier)` references resolve to exactly the same object. Any embedded
|
|
78
|
+
* `"` is doubled per SQL identifier-quoting rules.
|
|
79
|
+
*/
|
|
80
|
+
export const quoteQualified = name => name.split('.').map(atom => `"${atom.replace(/"/g, '""')}"`).join('.');
|
|
81
|
+
/**
|
|
82
|
+
* A quoted single-atom identifier (no dot-splitting) — used where a bare name is
|
|
83
|
+
* required, e.g. index/schema names.
|
|
84
|
+
*/
|
|
85
|
+
const quoteAtom = name => `"${name.replace(/"/g, '""')}"`;
|
|
86
|
+
/**
|
|
87
|
+
* A single-quoted SQL string LITERAL (not an identifier). Used for the
|
|
88
|
+
* `pg_notify(channel, payload)` channel argument, which is a `text` VALUE, not
|
|
89
|
+
* an identifier — double-quoting it there would (wrongly) parse as a column
|
|
90
|
+
* reference. Embedded single quotes are doubled per SQL string-literal rules.
|
|
91
|
+
*/
|
|
92
|
+
const quoteLiteral = value => `'${value.replace(/'/g, "''")}'`;
|
|
93
|
+
/**
|
|
94
|
+
* The autovacuum storage parameters, ported verbatim from upstream
|
|
95
|
+
* `postgres_tt.py`: the event log is append-heavy and effectively immutable, so
|
|
96
|
+
* vacuum is tuned to run rarely (huge thresholds) while analyze stays keen
|
|
97
|
+
* (small thresholds) to keep the planner's row estimates fresh for the tag-first
|
|
98
|
+
* CTE. Applied identically to both tables.
|
|
99
|
+
*/
|
|
100
|
+
const AUTOVACUUM_WITH = 'autovacuum_enabled = true, ' + 'autovacuum_vacuum_threshold = 100000000, ' + 'autovacuum_vacuum_scale_factor = 0.5, ' + 'autovacuum_analyze_threshold = 1000, ' + 'autovacuum_analyze_scale_factor = 0.01';
|
|
101
|
+
/**
|
|
102
|
+
* The `LOCK TABLE <main> IN EXCLUSIVE MODE;` clause — the serialisation point of
|
|
103
|
+
* the whole engine. `EXCLUSIVE` blocks other writers (and other conditional
|
|
104
|
+
* appends) but lets readers proceed, so the check-then-insert of one append
|
|
105
|
+
* cannot interleave with another's: exactly-one-wins holds by construction, with
|
|
106
|
+
* no dependence on isolation level or unique constraints.
|
|
107
|
+
*
|
|
108
|
+
* It is a SEPARATE, OMITTABLE fragment purely so the test-only lockless twin can
|
|
109
|
+
* drop it. Production always includes it.
|
|
110
|
+
*/
|
|
111
|
+
const lockClause = names => `LOCK TABLE ${quoteQualified(names.mainTable)} IN EXCLUSIVE MODE;`;
|
|
112
|
+
/**
|
|
113
|
+
* The shared insert → tag fan-out → NOTIFY body, emitting into a local
|
|
114
|
+
* `new_head bigint` and then `RETURN QUERY SELECT new_head`. Written as explicit
|
|
115
|
+
* sequential statements (not one mega-CTE) so control flow is obvious and the
|
|
116
|
+
* `pg_notify` fires unconditionally after a successful insert rather than
|
|
117
|
+
* depending on a data-modifying CTE being referenced.
|
|
118
|
+
*
|
|
119
|
+
* A single `INSERT … SELECT … FROM jsonb_to_recordset(…) ORDER BY ordinality`
|
|
120
|
+
* preserves batch order, and `RETURNING id, tags` is captured into a temp
|
|
121
|
+
* result set so the tag fan-out and the head can both read it. `data` arrives as
|
|
122
|
+
* a base64 text field and is `decode(…, 'base64')`-restored to `bytea`.
|
|
123
|
+
*/
|
|
124
|
+
const insertFanoutNotify = (names, options = {}) => {
|
|
125
|
+
const main = quoteQualified(names.mainTable);
|
|
126
|
+
const tag = quoteQualified(names.tagTable);
|
|
127
|
+
const channel = quoteLiteral(names.channel);
|
|
128
|
+
// `notify` is an OMITTABLE fragment (like the lock): production always
|
|
129
|
+
// notifies; the poll-only delivery test `CREATE OR REPLACE`s a NOTIFY-suppressed
|
|
130
|
+
// UNCONDITIONAL twin over its own prefix to prove the periodic poll — not
|
|
131
|
+
// NOTIFY — is the delivery guarantee. The conditional body never suppresses it.
|
|
132
|
+
const notify = options.notify ?? true ? `
|
|
133
|
+
-- Wake subscribers only if we actually inserted rows. NOTIFY is a payload-less
|
|
134
|
+
-- signal in spirit; a constant '1' payload is sent purely because the built-in
|
|
135
|
+
-- ref-counted listen client drops empty-payload notifications — the TS side
|
|
136
|
+
-- treats every notification as an opaque wake and ignores the payload.
|
|
137
|
+
IF new_head IS NOT NULL THEN
|
|
138
|
+
PERFORM pg_notify(${channel}, '1');
|
|
139
|
+
END IF;` : '';
|
|
140
|
+
return ` WITH ne AS (
|
|
141
|
+
SELECT type, data, tags, uuid, occurred_at, ordinality
|
|
142
|
+
FROM ROWS FROM(
|
|
143
|
+
jsonb_to_recordset(new_events) AS (${eventRecordsetColumns})
|
|
144
|
+
) WITH ORDINALITY
|
|
145
|
+
),
|
|
146
|
+
inserted AS (
|
|
147
|
+
INSERT INTO ${main} (type, data, tags, uuid, occurred_at)
|
|
148
|
+
SELECT ne.type, decode(ne.data, 'base64'), ne.tags, ne.uuid,
|
|
149
|
+
ne.occurred_at
|
|
150
|
+
FROM ne
|
|
151
|
+
ORDER BY ne.ordinality
|
|
152
|
+
RETURNING id, tags
|
|
153
|
+
),
|
|
154
|
+
fanned AS (
|
|
155
|
+
INSERT INTO ${tag} (tag, main_id)
|
|
156
|
+
SELECT unnest(inserted.tags), inserted.id
|
|
157
|
+
FROM inserted
|
|
158
|
+
RETURNING main_id
|
|
159
|
+
)
|
|
160
|
+
-- fanned is a DATA-MODIFYING CTE: Postgres runs it exactly once to completion
|
|
161
|
+
-- whether or not the primary query reads its output, so the tag fan-out
|
|
162
|
+
-- happens even though nothing selects from it — and so a count(*) over it
|
|
163
|
+
-- would add nothing but work.
|
|
164
|
+
SELECT MAX(inserted.id)
|
|
165
|
+
INTO new_head
|
|
166
|
+
FROM inserted;
|
|
167
|
+
${notify}`;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Build the body of the CONDITIONAL append function (lock → check → insert →
|
|
171
|
+
* notify), ported from upstream `dcb_conditional_append_tt` with the
|
|
172
|
+
* JSON-transport divergence (ADR-0002).
|
|
173
|
+
*
|
|
174
|
+
* Module-private: the ONLY caller is `appendFunctionStatement`, which owns the
|
|
175
|
+
* full `CREATE OR REPLACE` wrapper. Keeping the body builders unexported means a
|
|
176
|
+
* caller cannot re-introduce a hand-rolled wrapper (the drift this consolidation
|
|
177
|
+
* removes) — the wrapper is single-sourced.
|
|
178
|
+
*/
|
|
179
|
+
const conditionalAppendBody = (names, options = {}) => {
|
|
180
|
+
const main = quoteQualified(names.mainTable);
|
|
181
|
+
const tag = quoteQualified(names.tagTable);
|
|
182
|
+
const lock = options.lock ?? true ? ` ${lockClause(names)}\n` : '';
|
|
183
|
+
// The conditional body always notifies — `insertFanoutNotify` defaults to it,
|
|
184
|
+
// and there is deliberately no `notify` seam here: only the unconditional twin
|
|
185
|
+
// is ever notify-suppressed, by the poll-only delivery test.
|
|
186
|
+
// The tagged branch shares its match SQL with the TS read path via one builder
|
|
187
|
+
// (`internal/matchSql.ts`) — plpgsql parameter tokens here (`query_items`,
|
|
188
|
+
// `COALESCE(after_id,0)`), bound `$n` on the read side. The wildcard/tagless
|
|
189
|
+
// branches are conflict-only (the read dispatches those shapes in TS) and reuse
|
|
190
|
+
// `qi` + the shared allowed-types predicate.
|
|
191
|
+
//
|
|
192
|
+
// No compiler reaches any of this. Core's `ServableQueryShape` appears nowhere in
|
|
193
|
+
// the body text, so a member added to that union is invisible here, where
|
|
194
|
+
// `internal/readPlan.ts`'s exhaustive `switch` stops compiling — and what stands
|
|
195
|
+
// in for the compiler is the generality the body comment below claims. Worth
|
|
196
|
+
// naming is why that claim holds: the three contributions partition a query by
|
|
197
|
+
// its ITEMS — none at all, an item carrying no tags, an item carrying tags —
|
|
198
|
+
// which between them cover every `Query` value the grammar can express, so a new
|
|
199
|
+
// SHAPE changes which SQL the read compiles without changing what this body has
|
|
200
|
+
// to match. The exposure is the GRAMMAR itself: a new `QueryItem` field, or a new
|
|
201
|
+
// meaning for an existing one, is something this body ignores rather than fails
|
|
202
|
+
// on, and no `switch` over the shape union would have caught that either.
|
|
203
|
+
const after = 'COALESCE(after_id, 0)';
|
|
204
|
+
return `
|
|
205
|
+
DECLARE
|
|
206
|
+
conflict_exists boolean;
|
|
207
|
+
new_head bigint;
|
|
208
|
+
BEGIN
|
|
209
|
+
-- lock_timeout is set per-call by the caller via set_config(..., is_local),
|
|
210
|
+
-- so the same function honours the configured timeout without recompilation.
|
|
211
|
+
${lock} -- Conflict check, reduced to EXISTS/LIMIT 1: does ANY event after
|
|
212
|
+
-- COALESCE(after_id, 0) satisfy the guard query (items OR'd; within an item,
|
|
213
|
+
-- types OR-filter AND tags AND-superset)? Three contributions, OR'd:
|
|
214
|
+
-- * the WILDCARD (empty query_items) - any event after after_id conflicts;
|
|
215
|
+
-- * TAGLESS items (empty tags, maybe types) - a type-only / match-all item,
|
|
216
|
+
-- matched by type = ANY(types) (or any type when types is empty);
|
|
217
|
+
-- * TAGGED items - the shared tag-first match chain, identical to the read.
|
|
218
|
+
-- The servable grammar (assertServableQuery) guarantees a tagless item only
|
|
219
|
+
-- appears as the whole single-item query or the wildcard, but the check is
|
|
220
|
+
-- written generically so plain-data assembly cannot surprise it.
|
|
221
|
+
WITH ${taggedMatchChain({
|
|
222
|
+
queryItemsExpr: 'query_items',
|
|
223
|
+
afterExpr: after,
|
|
224
|
+
main,
|
|
225
|
+
tag
|
|
226
|
+
})},
|
|
227
|
+
-- Wildcard: no items at all means "match everything".
|
|
228
|
+
wildcard_conflict AS (
|
|
229
|
+
SELECT 1
|
|
230
|
+
FROM ${main} m
|
|
231
|
+
WHERE NOT EXISTS (SELECT 1 FROM qi)
|
|
232
|
+
AND m.id > ${after}
|
|
233
|
+
LIMIT 1
|
|
234
|
+
),
|
|
235
|
+
-- Tagless items: match by type (or any type when the item has no types).
|
|
236
|
+
tagless_conflict AS (
|
|
237
|
+
SELECT 1
|
|
238
|
+
FROM qi
|
|
239
|
+
JOIN ${main} m ON m.id > ${after}
|
|
240
|
+
WHERE (qi.tags IS NULL OR array_length(qi.tags, 1) IS NULL)
|
|
241
|
+
AND ${allowedTypesPredicate('qi.types')}
|
|
242
|
+
LIMIT 1
|
|
243
|
+
),
|
|
244
|
+
-- Tagged items: distinct-tag-count AND-superset matching (the shared source).
|
|
245
|
+
tagged_conflict AS (
|
|
246
|
+
SELECT 1
|
|
247
|
+
${qualifiedRows({
|
|
248
|
+
main,
|
|
249
|
+
afterExpr: after
|
|
250
|
+
})}
|
|
251
|
+
LIMIT 1
|
|
252
|
+
)
|
|
253
|
+
SELECT
|
|
254
|
+
EXISTS (SELECT 1 FROM wildcard_conflict)
|
|
255
|
+
OR EXISTS (SELECT 1 FROM tagless_conflict)
|
|
256
|
+
OR EXISTS (SELECT 1 FROM tagged_conflict)
|
|
257
|
+
INTO conflict_exists;
|
|
258
|
+
|
|
259
|
+
IF NOT conflict_exists THEN
|
|
260
|
+
-- No conflict: insert the batch, fan tags out, wake subscribers, return head.
|
|
261
|
+
${insertFanoutNotify(names)}
|
|
262
|
+
RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
|
|
263
|
+
END IF;
|
|
264
|
+
-- conflict_exists = true falls through, returning zero rows = a conflict.
|
|
265
|
+
RETURN;
|
|
266
|
+
END;
|
|
267
|
+
`;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Build the body of the UNCONDITIONAL append function: no conflict check, but the
|
|
271
|
+
* SAME `LOCK TABLE … IN EXCLUSIVE MODE` as the conditional twin.
|
|
272
|
+
*
|
|
273
|
+
* WHY the lock (a deliberate divergence from upstream's lock-free twin, ADR-0002):
|
|
274
|
+
* `bigserial` ids are allocated NON-transactionally, so two lock-free
|
|
275
|
+
* unconditional writers can allocate ids 5 and 6 and COMMIT them in reverse
|
|
276
|
+
* order. Commit-order then diverges from id-order, and two invariants the rest of
|
|
277
|
+
* the engine leans on silently break:
|
|
278
|
+
*
|
|
279
|
+
* 1. a `subscribe` poll that lands between the two commits sees id 6, advances
|
|
280
|
+
* its strict `> cursor` past 6, and PERMANENTLY skips id 5 when it commits
|
|
281
|
+
* milliseconds later — silent event loss on the live tail;
|
|
282
|
+
* 2. a no-limit read taken while the lower id is in flight returns `head` PAST
|
|
283
|
+
* an id no snapshot can yet see, so a decision model appending under
|
|
284
|
+
* `{ after: head }` never examines that id as a conflict — an escaped guard.
|
|
285
|
+
*
|
|
286
|
+
* The check-free FAST PATH is preserved (no conflict CTE); only the lock is
|
|
287
|
+
* added. It restores commit-order = id-order for EVERY append, making ADR-0002's
|
|
288
|
+
* "every append participates in the same mechanism by construction" true in the
|
|
289
|
+
* strong sense. The cost — unconditional writers serialise with all other writers
|
|
290
|
+
* — is exactly the write ceiling ADR-0002 already accepts and marks benchmarkable.
|
|
291
|
+
*
|
|
292
|
+
* `options.lock` is the same OMITTABLE-fragment seam as `conditionalAppendBody`:
|
|
293
|
+
* production defaults to `true`; the commit-order repro passes `{ lock: false }`
|
|
294
|
+
* over its own prefix to prove the subscribe-skip / head-overtake scenario
|
|
295
|
+
* genuinely reproduces the bug when the lock is absent (non-vacuity), exactly as
|
|
296
|
+
* the lockless meta-test does for the conditional path.
|
|
297
|
+
*
|
|
298
|
+
* `options.notify` is the analogous seam for the poll-only delivery test — a
|
|
299
|
+
* NOTIFY-suppressed twin (`{ notify: false }`) proves the periodic poll (not
|
|
300
|
+
* NOTIFY) is the delivery guarantee. Production always notifies.
|
|
301
|
+
*
|
|
302
|
+
* Module-private, like `conditionalAppendBody`: reached only via
|
|
303
|
+
* `appendFunctionStatement`.
|
|
304
|
+
*/
|
|
305
|
+
const unconditionalAppendBody = (names, options = {}) => {
|
|
306
|
+
const lock = options.lock ?? true ? ` ${lockClause(names)}\n` : '';
|
|
307
|
+
// Pass the `notify` option straight through — no unwrap-then-rewrap;
|
|
308
|
+
// `insertFanoutNotify` owns the default.
|
|
309
|
+
return `
|
|
310
|
+
DECLARE
|
|
311
|
+
new_head bigint;
|
|
312
|
+
BEGIN
|
|
313
|
+
${lock}${insertFanoutNotify(names, options)}
|
|
314
|
+
RETURN QUERY SELECT new_head WHERE new_head IS NOT NULL;
|
|
315
|
+
END;
|
|
316
|
+
`;
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* Build ONE complete `CREATE OR REPLACE FUNCTION … RETURNS SETOF bigint LANGUAGE
|
|
320
|
+
* plpgsql AS $kairos$…$kairos$` statement for an append function. This is the
|
|
321
|
+
* single source of the wrapper — its qualified name, argument SIGNATURE, and
|
|
322
|
+
* `$kairos$`-delimited body — consumed by `ddlStatements` (production, both twins)
|
|
323
|
+
* AND by the test installers that `CREATE OR REPLACE` a lockless / notify-
|
|
324
|
+
* suppressed twin over their own prefix.
|
|
325
|
+
*
|
|
326
|
+
* WHY one builder: `ddlStatements` plus three test twins previously hand-restated
|
|
327
|
+
* this wrapper, several with string-INTERPOLATED identifiers this module's own
|
|
328
|
+
* doctrine forbids (`"${schema}"."${prefix}_append"` rather than
|
|
329
|
+
* `quoteQualified(names.appendFn)`). A drift in the SIGNATURE across those copies
|
|
330
|
+
* is a silent footgun: `CREATE OR REPLACE` with a changed argument list creates a
|
|
331
|
+
* new OVERLOAD instead of replacing, leaving the original production function live
|
|
332
|
+
* under test. Single-sourcing name + signature makes a twin unable to diverge —
|
|
333
|
+
* a twin `CREATE OR REPLACE`s exactly the function `ddlStatements` created.
|
|
334
|
+
*/
|
|
335
|
+
export const appendFunctionStatement = (names, options) => {
|
|
336
|
+
// Only the qualified name, the argument SIGNATURE, and the body differ between
|
|
337
|
+
// the twins; the `CREATE OR REPLACE … RETURNS SETOF bigint … $kairos$…$kairos$`
|
|
338
|
+
// wrapper is one template, emitted once rather than restated per arm of the
|
|
339
|
+
// ternary below.
|
|
340
|
+
const [qualifiedName, argList, body] = options.kind === 'conditional' ? [names.appendFn, 'query_items jsonb, after_id bigint, new_events jsonb', conditionalAppendBody(names, options)] : [names.appendUnconditionalFn, 'new_events jsonb', unconditionalAppendBody(names, options)];
|
|
341
|
+
return `CREATE OR REPLACE FUNCTION ${quoteQualified(qualifiedName)}(
|
|
342
|
+
${argList}
|
|
343
|
+
) RETURNS SETOF bigint
|
|
344
|
+
LANGUAGE plpgsql AS $kairos$${body}$kairos$;`;
|
|
345
|
+
};
|
|
346
|
+
/**
|
|
347
|
+
* The full ordered list of idempotent DDL statements that create/refresh a
|
|
348
|
+
* store's objects. Each entry is a `sql.unsafe` statement string; VALUES are not
|
|
349
|
+
* involved (pure DDL), and every identifier is pre-quoted via `quoteQualified`
|
|
350
|
+
* to match the runtime's `sql(identifier)` resolution.
|
|
351
|
+
*
|
|
352
|
+
* Ordering matters: tables before their indexes and before the tag table's FK,
|
|
353
|
+
* functions last (they reference the tables). Everything is `IF NOT EXISTS` /
|
|
354
|
+
* `CREATE OR REPLACE`, so re-running is a no-op — the migration payload is safe
|
|
355
|
+
* to register in any migrator or to run directly.
|
|
356
|
+
*/
|
|
357
|
+
export const ddlStatements = names => {
|
|
358
|
+
const main = quoteQualified(names.mainTable);
|
|
359
|
+
const tag = quoteQualified(names.tagTable);
|
|
360
|
+
const schema = quoteAtom(names.schema);
|
|
361
|
+
const mainIndex = quoteAtom(names.mainIndex);
|
|
362
|
+
const tagIndex = quoteAtom(names.tagIndex);
|
|
363
|
+
return [
|
|
364
|
+
// The schema may already exist (e.g. `public`); create it defensively so a
|
|
365
|
+
// custom schema does not require a separate provisioning step.
|
|
366
|
+
`CREATE SCHEMA IF NOT EXISTS ${schema};`,
|
|
367
|
+
// Main event table. `id bigserial` gives strictly-increasing (NOT gapless)
|
|
368
|
+
// positions — a rolled-back insert permanently consumes its id, which is
|
|
369
|
+
// fine: ordering is by id alone and the store is never made gapless.
|
|
370
|
+
// `data` is NULLABLE (a payload-less event is valid); `occurred_at` is
|
|
371
|
+
// informational domain time, never an ordering key.
|
|
372
|
+
`CREATE TABLE IF NOT EXISTS ${main} (
|
|
373
|
+
id bigserial,
|
|
374
|
+
type text NOT NULL,
|
|
375
|
+
data bytea,
|
|
376
|
+
tags text[] NOT NULL,
|
|
377
|
+
uuid text NOT NULL,
|
|
378
|
+
occurred_at timestamptz NOT NULL
|
|
379
|
+
) WITH (${AUTOVACUUM_WITH});`,
|
|
380
|
+
// Covering unique index: uniqueness on id plus INCLUDE(type) so the read
|
|
381
|
+
// path's id→type lookups are index-only.
|
|
382
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${mainIndex}
|
|
383
|
+
ON ${main} (id) INCLUDE (type);`,
|
|
384
|
+
// Junction tag table: one row per (tag, event) OCCURRENCE, under NO
|
|
385
|
+
// uniqueness constraint — the fan-out is a plain `unnest(tags)`, so an event
|
|
386
|
+
// whose own `tags` list repeats a tag lands TWO rows here. That is not a
|
|
387
|
+
// defect to close by constraining the table: a repeated tag is legal on an
|
|
388
|
+
// event and carries no information, so refusing it would break parity with
|
|
389
|
+
// the in-memory oracle over a physical-design decision the contract knows
|
|
390
|
+
// nothing about. The match side absorbs it instead, by counting DISTINCT tags
|
|
391
|
+
// per event — `internal/matchSql.ts` owns what that buys and what it obliges
|
|
392
|
+
// the requirement count to be.
|
|
393
|
+
//
|
|
394
|
+
// The `main_id … REFERENCES` FK ties a tag row to its event; it is a VERBATIM
|
|
395
|
+
// port of upstream `postgres_tt.py` (its junction DDL carries the same
|
|
396
|
+
// `main_id bigint REFERENCES {events_table} (id)`), not an addition —
|
|
397
|
+
// ADR-0002 adopts that design. Both inserts run in one
|
|
398
|
+
// statement/transaction so the referenced main row is always visible to the
|
|
399
|
+
// tag insert. Same autovacuum tuning as the main table.
|
|
400
|
+
`CREATE TABLE IF NOT EXISTS ${tag} (
|
|
401
|
+
tag text,
|
|
402
|
+
main_id bigint REFERENCES ${main} (id)
|
|
403
|
+
) WITH (${AUTOVACUUM_WITH});`,
|
|
404
|
+
// Composite B-tree (tag, main_id): the tag-first CTE probes by tag then
|
|
405
|
+
// joins by main_id, so this index serves both the conflict check and reads.
|
|
406
|
+
`CREATE INDEX IF NOT EXISTS ${tagIndex}
|
|
407
|
+
ON ${tag} (tag, main_id);`,
|
|
408
|
+
// Conditional append (production): lock → check → insert → notify.
|
|
409
|
+
appendFunctionStatement(names, {
|
|
410
|
+
kind: 'conditional'
|
|
411
|
+
}),
|
|
412
|
+
// Unconditional twin: lock → insert → notify, no conflict check.
|
|
413
|
+
appendFunctionStatement(names, {
|
|
414
|
+
kind: 'unconditional'
|
|
415
|
+
})];
|
|
416
|
+
};
|
|
417
|
+
/**
|
|
418
|
+
* Run the ordered DDL against the generic `SqlClient`. Kept here (not in the
|
|
419
|
+
* public `ensureSchema`) so the same builder feeds both the public migration
|
|
420
|
+
* payload and any internal test setup. Each statement is a separate `sql.unsafe`
|
|
421
|
+
* call so a driver that rejects multi-statement strings still works.
|
|
422
|
+
*/
|
|
423
|
+
export const runDdl = (sql, names) => ddlStatements(names).map(statement => sql.unsafe(statement));
|
|
424
|
+
//# sourceMappingURL=ddl.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ddl.js","names":["allowedTypesPredicate","eventRecordsetColumns","qualifiedRows","taggedMatchChain","DEFAULT_SCHEMA","DEFAULT_TABLE_PREFIX","MAX_IDENTIFIER_BYTES","utf8ByteLength","value","TextEncoder","encode","length","derivedAtoms","config","schema","tablePrefix","replace","oversizedIdentifier","label","atom","bytes","undefined","resolveNames","problem","Error","mainTable","tagTable","mainIndex","tagIndex","appendFn","appendUnconditionalFn","channel","quoteQualified","name","split","map","join","quoteAtom","quoteLiteral","AUTOVACUUM_WITH","lockClause","names","insertFanoutNotify","options","main","tag","notify","conditionalAppendBody","lock","after","queryItemsExpr","afterExpr","unconditionalAppendBody","appendFunctionStatement","qualifiedName","argList","body","kind","ddlStatements","runDdl","sql","statement","unsafe"],"sources":["../../../src/internal/ddl.ts"],"sourcesContent":[null],"mappings":"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","ignoreList":[]}
|
|
@@ -0,0 +1,176 @@
|
|
|
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
|
+
const EVENT_COLUMNS = [['type', 'text'], ['data', 'text'], ['tags', 'text[]'], ['uuid', 'text'], ['occurred_at', 'timestamptz']];
|
|
36
|
+
/**
|
|
37
|
+
* The `jsonb_to_recordset(new_events) AS (…)` column definitions —
|
|
38
|
+
* `type text, data text, tags text[], uuid text, occurred_at timestamptz`. The
|
|
39
|
+
* transport `data` is base64 TEXT (restored to `bytea` by `decode(…, 'base64')`
|
|
40
|
+
* at insert), which is why it is `text` here, not `bytea`.
|
|
41
|
+
*/
|
|
42
|
+
export const eventRecordsetColumns = /*#__PURE__*/EVENT_COLUMNS.map(([name, type]) => `${name} ${type}`).join(', ');
|
|
43
|
+
/**
|
|
44
|
+
* The read projection column list (`id` + the event columns), optionally
|
|
45
|
+
* prefixed with a table alias (`'m.'`). Sourced from the one descriptor so it
|
|
46
|
+
* cannot drift from the recordset.
|
|
47
|
+
*/
|
|
48
|
+
export const readColumns = (prefix = '') => [`${prefix}id`, ...EVENT_COLUMNS.map(([name]) => `${prefix}${name}`)].join(', ');
|
|
49
|
+
/**
|
|
50
|
+
* The allowed-types filter for a candidate row aliased `m`: an item with no
|
|
51
|
+
* types matches ANY type; otherwise the event's type must be one of them.
|
|
52
|
+
* `typesCol` is the `text[]` column holding the item's types — `q.allowed_types`
|
|
53
|
+
* for a tagged match, `qi.types` for a tagless one.
|
|
54
|
+
*/
|
|
55
|
+
export const allowedTypesPredicate = typesCol => `(array_length(${typesCol}, 1) IS NULL OR array_length(${typesCol}, 1) = 0 OR m.type = ANY(${typesCol}))`;
|
|
56
|
+
/**
|
|
57
|
+
* The tag-first match CTE chain shared by the read and conflict paths: expand
|
|
58
|
+
* the query items (`qi`), join the tag junction, count DISTINCT matched tags per
|
|
59
|
+
* (event, item), and keep events matching an item's FULL tag superset. Returns
|
|
60
|
+
* `qi, initial_matches, matched_groups, qualified_ids` — the caller follows it
|
|
61
|
+
* with a projection CTE selecting from `qualified_ids`.
|
|
62
|
+
*
|
|
63
|
+
* ## Both sides of the count are SET SIZES, and that is the whole invariant
|
|
64
|
+
*
|
|
65
|
+
* `qualified_ids` compares two numbers, and the ONE thing an edit here must
|
|
66
|
+
* preserve is that they count the same kind of thing: `matched_tag_count` is the
|
|
67
|
+
* number of DISTINCT tags the event carried out of the item's requirement, so
|
|
68
|
+
* `required_tag_count` is the size of that requirement AS A SET and never the
|
|
69
|
+
* length of the list it arrived as. A query item may list one tag twice — no
|
|
70
|
+
* constructor de-duplicates, `assertServableQuery` admits it and
|
|
71
|
+
* `classifyServableQuery` routes it here — and the in-memory oracle folds an
|
|
72
|
+
* item's tags into a superset test that a repeat cannot change. Comparing a
|
|
73
|
+
* de-duplicated 1 against a list length of 2 refused such a match: the READ
|
|
74
|
+
* returned nothing and, because the plpgsql conflict body composes this same
|
|
75
|
+
* chain, the GUARD found no conflict and the append it should have refused
|
|
76
|
+
* succeeded — an optimistic-concurrency check that escaped silently.
|
|
77
|
+
*
|
|
78
|
+
* The asymmetry has two repairs — de-duplicate both sides, or expand both — and
|
|
79
|
+
* only the first is available here. The matched side CANNOT become a plain row
|
|
80
|
+
* count, because this engine's junction holds one row per (tag, event) OCCURRENCE
|
|
81
|
+
* and not per pair, so an event whose own `tags` list repeats a tag has two rows
|
|
82
|
+
* there and a row count would refuse a single-tag item against it. `ddl.ts` states
|
|
83
|
+
* that property on the junction, and it is what `COUNT(DISTINCT tag)` is here to
|
|
84
|
+
* absorb; the requirement is de-duplicated to meet it.
|
|
85
|
+
*
|
|
86
|
+
* `required_tag_count` is therefore computed in `qi`, once per ITEM, rather than
|
|
87
|
+
* in the group that consumes it — the chain groups per (event, item), so a
|
|
88
|
+
* subquery in `matched_groups` would be re-evaluated per matched event. It is
|
|
89
|
+
* `count(*)` over a `SELECT DISTINCT` rather than `count(DISTINCT …)` because the
|
|
90
|
+
* latter drops a NULL element: a `[tag, NULL]` requirement would shrink to 1,
|
|
91
|
+
* meet a single matched tag and match, where the oracle's superset test cannot be
|
|
92
|
+
* satisfied by a NULL and refuses it.
|
|
93
|
+
*
|
|
94
|
+
* The `array_length(qi.tags, 1) >= 1` guard excludes tagless items from the
|
|
95
|
+
* tagged branch (load-bearing for the conflict body, which also has a separate
|
|
96
|
+
* tagless branch; a no-op for the read, whose tagged shape is all-tagged). It
|
|
97
|
+
* reads the item's own array because it is a test of the item's SHAPE — tagged or
|
|
98
|
+
* tagless — and not part of the match arithmetic above.
|
|
99
|
+
*/
|
|
100
|
+
export const taggedMatchChain = tokens => `qi AS (
|
|
101
|
+
SELECT types, tags, ordinality,
|
|
102
|
+
-- Both sides of the match compare DISTINCT-TAG counts: this is the
|
|
103
|
+
-- requirement's SET size, never the length of the list it arrived as.
|
|
104
|
+
(SELECT count(*) FROM (SELECT DISTINCT req.tag FROM unnest(tags) AS req(tag)) d)
|
|
105
|
+
AS required_tag_count
|
|
106
|
+
FROM ROWS FROM(
|
|
107
|
+
jsonb_to_recordset(${tokens.queryItemsExpr}) AS (types text[], tags text[])
|
|
108
|
+
) WITH ORDINALITY
|
|
109
|
+
),
|
|
110
|
+
initial_matches AS (
|
|
111
|
+
SELECT t.main_id, qi.ordinality, qi.required_tag_count,
|
|
112
|
+
qi.types AS allowed_types, t.tag
|
|
113
|
+
FROM qi
|
|
114
|
+
JOIN ${tokens.tag} t ON t.tag = ANY(qi.tags)
|
|
115
|
+
WHERE t.main_id > ${tokens.afterExpr}
|
|
116
|
+
AND array_length(qi.tags, 1) >= 1
|
|
117
|
+
),
|
|
118
|
+
matched_groups AS (
|
|
119
|
+
SELECT main_id, ordinality, required_tag_count, allowed_types,
|
|
120
|
+
COUNT(DISTINCT tag) AS matched_tag_count
|
|
121
|
+
FROM initial_matches
|
|
122
|
+
GROUP BY main_id, ordinality, required_tag_count, allowed_types
|
|
123
|
+
),
|
|
124
|
+
qualified_ids AS (
|
|
125
|
+
SELECT main_id, allowed_types
|
|
126
|
+
FROM matched_groups
|
|
127
|
+
WHERE matched_tag_count = required_tag_count
|
|
128
|
+
)`;
|
|
129
|
+
/**
|
|
130
|
+
* The shared qualified-rows source: join `qualified_ids` back to the main table
|
|
131
|
+
* (aliased `m`), re-apply the exclusive `after` bound, and filter by allowed
|
|
132
|
+
* types. The read wraps this in an ordered/limited `DISTINCT m.id` CTE; the
|
|
133
|
+
* conflict wraps it in a `SELECT 1 … LIMIT 1` existence probe.
|
|
134
|
+
*/
|
|
135
|
+
export const qualifiedRows = tokens => `FROM qualified_ids q
|
|
136
|
+
JOIN ${tokens.main} m ON m.id = q.main_id
|
|
137
|
+
WHERE m.id > ${tokens.afterExpr}
|
|
138
|
+
AND ${allowedTypesPredicate('q.allowed_types')}`;
|
|
139
|
+
/** The wildcard read (no items): every event after the bound, ordered/limited. */
|
|
140
|
+
export const wildcardReadSql = main => `SELECT ${readColumns()}
|
|
141
|
+
FROM ${main}
|
|
142
|
+
WHERE id > $1
|
|
143
|
+
ORDER BY id ASC
|
|
144
|
+
LIMIT $2`;
|
|
145
|
+
/** The type-only read (single tagless item): filter by `type = ANY($2)`. */
|
|
146
|
+
export const typeOnlyReadSql = main => `SELECT ${readColumns()}
|
|
147
|
+
FROM ${main}
|
|
148
|
+
WHERE id > $1 AND type = ANY($2)
|
|
149
|
+
ORDER BY id ASC
|
|
150
|
+
LIMIT $3`;
|
|
151
|
+
/**
|
|
152
|
+
* The tagged read: the shared match chain, then a `filtered_ids` CTE that orders
|
|
153
|
+
* and limits the qualified ids by position, then a full-row select. Bound params
|
|
154
|
+
* are `$1` = query-items jsonb, `$2` = exclusive `after`, `$3` = limit (`NULL` =
|
|
155
|
+
* no limit).
|
|
156
|
+
*/
|
|
157
|
+
export const taggedReadSql = (main, tag) => `WITH ${taggedMatchChain({
|
|
158
|
+
queryItemsExpr: '$1::jsonb',
|
|
159
|
+
afterExpr: '$2',
|
|
160
|
+
main,
|
|
161
|
+
tag
|
|
162
|
+
})},
|
|
163
|
+
filtered_ids AS (
|
|
164
|
+
SELECT DISTINCT m.id
|
|
165
|
+
${qualifiedRows({
|
|
166
|
+
main,
|
|
167
|
+
afterExpr: '$2'
|
|
168
|
+
})}
|
|
169
|
+
ORDER BY m.id ASC
|
|
170
|
+
LIMIT $3
|
|
171
|
+
)
|
|
172
|
+
SELECT ${readColumns('m.')}
|
|
173
|
+
FROM ${main} m
|
|
174
|
+
WHERE m.id IN (SELECT id FROM filtered_ids)
|
|
175
|
+
ORDER BY m.id ASC`;
|
|
176
|
+
//# sourceMappingURL=matchSql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"matchSql.js","names":["EVENT_COLUMNS","eventRecordsetColumns","map","name","type","join","readColumns","prefix","allowedTypesPredicate","typesCol","taggedMatchChain","tokens","queryItemsExpr","tag","afterExpr","qualifiedRows","main","wildcardReadSql","typeOnlyReadSql","taggedReadSql"],"sources":["../../../src/internal/matchSql.ts"],"sourcesContent":[null],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;AAwBA;;;;;;;;;;;AAWA,MAAMA,aAAa,GAAG,CACpB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,MAAM,EAAE,QAAQ,CAAC,EAClB,CAAC,MAAM,EAAE,MAAM,CAAC,EAChB,CAAC,aAAa,EAAE,aAAa,CAAC,CAG/B;AAYD;;;;;;AAMA,OAAO,MAAMC,qBAAqB,gBAAWD,aAAa,CAACE,GAAG,CAC5D,CAAC,CAACC,IAAI,EAAEC,IAAI,CAAC,KAAK,GAAGD,IAAI,IAAIC,IAAI,EAAE,CACpC,CAACC,IAAI,CAAC,IAAI,CAAC;AAEZ;;;;;AAKA,OAAO,MAAMC,WAAW,GAAGA,CAACC,MAAM,GAAG,EAAE,KACrC,CAAC,GAAGA,MAAM,IAAI,EAAE,GAAGP,aAAa,CAACE,GAAG,CAAC,CAAC,CAACC,IAAI,CAAC,KAAK,GAAGI,MAAM,GAAGJ,IAAI,EAAE,CAAC,CAAC,CAACE,IAAI,CACxE,IAAI,CACL;AAEH;;;;;;AAMA,OAAO,MAAMG,qBAAqB,GAAIC,QAAgB,IACpD,iBAAiBA,QAAQ,gCAAgCA,QAAQ,4BAA4BA,QAAQ,IAAI;AAc3G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CA,OAAO,MAAMC,gBAAgB,GAAIC,MAAmB,IAClD;;;;;;;2BAOyBA,MAAM,CAACC,cAAc;;;;;;;WAOrCD,MAAM,CAACE,GAAG;wBACGF,MAAM,CAACG,SAAS;;;;;;;;;;;;;IAapC;AAEJ;;;;;;AAMA,OAAO,MAAMC,aAAa,GACxBJ,MAA+C,IAE/C;WACSA,MAAM,CAACK,IAAI;mBACHL,MAAM,CAACG,SAAS;YACvBN,qBAAqB,CAAC,iBAAiB,CAAC,EAAE;AAEtD;AACA,OAAO,MAAMS,eAAe,GAAID,IAAY,IAC1C,UAAUV,WAAW,EAAE;UACfU,IAAI;;;YAGF;AAEZ;AACA,OAAO,MAAME,eAAe,GAAIF,IAAY,IAC1C,UAAUV,WAAW,EAAE;UACfU,IAAI;;;YAGF;AAEZ;;;;;;AAMA,OAAO,MAAMG,aAAa,GAAGA,CAACH,IAAY,EAAEH,GAAW,KACrD,QAAQH,gBAAgB,CAAC;EAAEE,cAAc,EAAE,WAAW;EAAEE,SAAS,EAAE,IAAI;EAAEE,IAAI;EAAEH;AAAG,CAAE,CAAC;;;MAGjFE,aAAa,CAAC;EAAEC,IAAI;EAAEF,SAAS,EAAE;AAAI,CAAE,CAAC;;;;WAInCR,WAAW,CAAC,IAAI,CAAC;SACnBU,IAAI;;oBAEO","ignoreList":[]}
|