@thomasfosterau/legation 0.0.0 → 0.1.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.
@@ -0,0 +1,969 @@
1
+ import { t as __exportAll } from "../rolldown-runtime-D8jQpDG7.js";
2
+ import { $t as apply, Mn as BackendError, Sn as test, _ as transaction, b as make$3, dn as isIn, h as Backend, mn as isNullish, s as InvalidationBus, vt as contentHash, wn as filterDeclarationOf, xn as resolveExists } from "../MemoryBackend-DxP8t4ge.js";
3
+ import * as Arr from "effect/Array";
4
+ import * as Schema from "effect/Schema";
5
+ import * as Option from "effect/Option";
6
+ import * as P from "effect/Predicate";
7
+ import * as Effect from "effect/Effect";
8
+ import * as Ref from "effect/Ref";
9
+ import * as R from "effect/Record";
10
+ import * as Schedule from "effect/Schedule";
11
+ import * as MutableHashMap from "effect/MutableHashMap";
12
+ import * as MutableHashSet from "effect/MutableHashSet";
13
+ // src/sql/Sql.ts
14
+ var Sql_exports = /* @__PURE__ */ __exportAll({
15
+ compileSelect: () => compileSelect,
16
+ compileWhere: () => compileWhere,
17
+ postgres: () => postgres,
18
+ sqlite: () => sqlite
19
+ });
20
+ const quoteDouble = (identifier) => `"${identifier.replace(/"/g, "\"\"")}"`;
21
+ /**
22
+ * SQLite: `?` placeholders, `COLLATE BINARY`, booleans as `0`/`1`, a `UNION
23
+ * ALL` arm's row as its own columns by position (`pragma_table_info`).
24
+ */
25
+ const sqlite = {
26
+ placeholder: () => "?",
27
+ quote: quoteDouble,
28
+ textCollation: "BINARY",
29
+ boolean: (value) => value ? 1 : 0,
30
+ greatest: (left, right) => `MAX(${left}, ${right})`,
31
+ unionRows: {
32
+ _tag: "Columns",
33
+ columnsOf: (relation) => ({
34
+ text: `SELECT name FROM pragma_table_info(?)`,
35
+ params: [relation]
36
+ })
37
+ }
38
+ };
39
+ /**
40
+ * PostgreSQL: `$1`, `$2`, … placeholders, `COLLATE "C"`, native booleans,
41
+ * SERIALIZABLE transactions, a `UNION ALL` arm's row as JSON (`row_to_json`).
42
+ */
43
+ const postgres = {
44
+ placeholder: (index) => `$${index}`,
45
+ quote: quoteDouble,
46
+ textCollation: `"C"`,
47
+ boolean: (value) => value,
48
+ greatest: (left, right) => `GREATEST(${left}, ${right})`,
49
+ serializable: "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE",
50
+ unionRows: {
51
+ _tag: "Json",
52
+ rowJson: (alias) => `row_to_json(${alias})::text`
53
+ }
54
+ };
55
+ const comparators = {
56
+ eq: "=",
57
+ ne: "<>",
58
+ gt: ">",
59
+ gte: ">=",
60
+ lt: "<",
61
+ lte: "<="
62
+ };
63
+ /** Accumulates bind params so placeholders get the right 1-based index. */
64
+ var Params = class {
65
+ dialect;
66
+ values = [];
67
+ constructor(dialect) {
68
+ this.dialect = dialect;
69
+ }
70
+ bind(value) {
71
+ this.values.push(value);
72
+ return this.dialect.placeholder(this.values.length);
73
+ }
74
+ };
75
+ /** The JS type a literal of each kind has. */
76
+ const jsTypeOf = {
77
+ text: "string",
78
+ instant: "string",
79
+ integer: "number",
80
+ double: "number",
81
+ boolean: "boolean"
82
+ };
83
+ /** Whether a literal can bind as the declared kind: `null` always, else by JS type. */
84
+ const conforms = (kind, value) => value === null || kind === void 0 || typeof value === jsTypeOf[kind];
85
+ /** The column operand: quoted, with the collation clause when declared `text`. */
86
+ const operand = (compilation, field) => {
87
+ const column = compilation.dialect.quote(field);
88
+ return compilation.kinds?.[field] === "text" ? `${column} COLLATE ${compilation.dialect.textCollation}` : column;
89
+ };
90
+ /**
91
+ * Bind one literal under the field's declared kind: a conforming literal as
92
+ * itself (booleans as the dialect stores them), a cross-type literal as
93
+ * `NULL` (unknown, rule (d)).
94
+ */
95
+ const bindLiteral = (compilation, field, value) => {
96
+ const { params, dialect, kinds } = compilation;
97
+ if (!conforms(kinds?.[field], value)) return params.bind(null);
98
+ return params.bind(P.isBoolean(value) ? dialect.boolean(value) : value);
99
+ };
100
+ /**
101
+ * The literals of an `In`/`NotIn` list that can bind under the declared
102
+ * kind. A cross-type literal matches nothing, so it is dropped; a non-empty
103
+ * list left with nothing binds one `NULL`, which makes the membership
104
+ * unknown — `Predicate.evaluate`'s verdict when no listed literal has the
105
+ * field's type.
106
+ */
107
+ const bindableList = (compilation, field, values) => {
108
+ const kind = compilation.kinds?.[field];
109
+ const kept = values.filter((value) => conforms(kind, value));
110
+ return Arr.isReadonlyArrayEmpty(kept) ? [null] : kept;
111
+ };
112
+ const compileCompare = (node, compilation) => `${operand(compilation, node.field)} ${comparators[node.op]} ${bindLiteral(compilation, node.field, node.value)}`;
113
+ /** `In` / `NotIn`: an empty list is the constant `FALSE` / `TRUE` (ADR-0028 §3). */
114
+ const compileList = (node, compilation) => {
115
+ if (Arr.isReadonlyArrayEmpty(node.values)) return node._tag === "In" ? "1 = 0" : "1 = 1";
116
+ const placeholders = bindableList(compilation, node.field, node.values).map((value) => bindLiteral(compilation, node.field, value)).join(", ");
117
+ return `${operand(compilation, node.field)} ${node._tag === "In" ? "IN" : "NOT IN"} (${placeholders})`;
118
+ };
119
+ /** Correlated set membership: `localField IN (SELECT foreignField FROM … WHERE …)`. */
120
+ const compileExists = (node, compilation) => {
121
+ const kinds = compilation.foreignKinds?.[node.collection];
122
+ const inner = compileNode(node.where, {
123
+ ...compilation,
124
+ kinds
125
+ });
126
+ const { quote } = compilation.dialect;
127
+ return `${quote(node.localField)} IN (SELECT ${quote(node.foreignField)} FROM ${quote(node.collection)} WHERE ${inner})`;
128
+ };
129
+ const compileNode = (predicate, compilation) => {
130
+ switch (predicate._tag) {
131
+ case "True": return "1 = 1";
132
+ case "Compare": return compileCompare(predicate, compilation);
133
+ case "In":
134
+ case "NotIn": return compileList(predicate, compilation);
135
+ case "IsNull": return `${compilation.dialect.quote(predicate.field)} IS ${predicate.negated === true ? "NOT " : ""}NULL`;
136
+ case "And":
137
+ if (Arr.isReadonlyArrayEmpty(predicate.predicates)) return "1 = 1";
138
+ return predicate.predicates.map((inner) => compileNode(inner, compilation)).join(" AND ");
139
+ case "Or":
140
+ if (Arr.isReadonlyArrayEmpty(predicate.predicates)) return "1 = 0";
141
+ return `(${predicate.predicates.map((inner) => compileNode(inner, compilation)).join(" OR ")})`;
142
+ case "Not": return `NOT (${compileNode(predicate.predicate, compilation)})`;
143
+ case "Exists": return compileExists(predicate, compilation);
144
+ case "InSet": return "1 = 0";
145
+ }
146
+ };
147
+ /**
148
+ * Compile a predicate into a `WHERE`-ready fragment for a dialect. Pass
149
+ * `options.kinds` (field → literal kind) so declared-text fields get their
150
+ * collation clause and literals bind by the declared kind; without a
151
+ * declaration nothing collates and a literal binds as its JS type (see the
152
+ * module doc).
153
+ */
154
+ const compileWhere = (predicate, dialect = sqlite, options = {}) => {
155
+ const params = new Params(dialect);
156
+ return {
157
+ text: compileNode(predicate, {
158
+ dialect,
159
+ params,
160
+ kinds: options.kinds,
161
+ foreignKinds: options.foreignKinds
162
+ }),
163
+ params: params.values
164
+ };
165
+ };
166
+ /**
167
+ * Compile a `SELECT * FROM <table> WHERE <predicate>` for a subset. The
168
+ * `WHERE` is exact (see the module doc). Order, limit and cursor are not
169
+ * pushed down: this statement may later grow an `ORDER BY … LIMIT` for
170
+ * backends that want it, but today the backend's `Subset.apply` finalises
171
+ * them locally, so this stays a single, safe statement.
172
+ */
173
+ const compileSelect = (options) => {
174
+ const dialect = options.dialect ?? sqlite;
175
+ const { text, params } = compileWhere(options.where, dialect, {
176
+ kinds: options.kinds,
177
+ foreignKinds: options.foreignKinds
178
+ });
179
+ return {
180
+ text: `SELECT * FROM ${dialect.quote(options.table)} WHERE ${text}`,
181
+ params
182
+ };
183
+ };
184
+ // src/sql/SqlBackend.ts
185
+ /**
186
+ * SqlBackend
187
+ *
188
+ * A reference implementation of the core `Backend` contract (ADR-0009) over a
189
+ * SQL database, addressing the audit's "turnkey backend" gap with a
190
+ * driver-agnostic adapter: the app provides a tiny `SqlExecutor` (wrapping
191
+ * `better-sqlite3`, `effect/sql`, `pg`, …) and per-collection table
192
+ * mappings, and this backend supplies subset evaluation, row fetching, and the
193
+ * transactional mutation-id ledger.
194
+ *
195
+ * Reads go through the `Sql` translator (`Subset` predicate → `WHERE`, exact
196
+ * under ADR-0028 on both dialects), then `Subset.apply` finalises
197
+ * order/limit/cursor locally. A table mapping may declare its columns'
198
+ * literal kinds (`TableMapping.kinds`) so the compiler collates declared-text
199
+ * columns and binds literals by kind (booleans per dialect); the row decoder
200
+ * maps SQLite's `0`/`1` back to booleans for columns declared `boolean`, and
201
+ * otherwise leaves rows as the driver returns them (values stay opaque,
202
+ * ADR-0014). Writes run app-provided handlers inside one transaction (on an
203
+ * executor that has transactions), and each mutation is first claimed in the
204
+ * client's ledger row: a conditional UPDATE
205
+ * from `id - 1` to `id` that runs before its handler. The database evaluates
206
+ * that WHERE against the latest committed row, so of two calls racing for one
207
+ * mutation only one claim matches and the other is a replay the ledger covers.
208
+ * The watermark only ever moves from `id - 1` to `id`, so it cannot fall. This
209
+ * gives the contract's exactly-once, in-order guarantee for one client group,
210
+ * with the claim alone needing no stronger isolation than READ COMMITTED.
211
+ * An executor without transactions (`transactional: false`) cannot commit a
212
+ * claim together with its handler's writes, so it keeps the handler-then-bump
213
+ * order instead (see D1 below).
214
+ *
215
+ * A permanent rejection is remembered in the client's ledger row, in the same
216
+ * transaction as the claim (#366), in a `rejections` column that holds a JSON
217
+ * object of mutation id → reason. A replayed copy lists its mutation in
218
+ * `rejected` with the stored reason, so every copy reports the committed outcome
219
+ * (guarantee 1). A copy that a concurrent call committed first is dropped on
220
+ * each dialect: on SQLite it lists its mutation in `rejected` directly; on
221
+ * PostgreSQL it is refused with a transient `BackendError` (SQLSTATE 40001), and
222
+ * its retry, a replay, lists it. A push forgets the rejections below its lowest
223
+ * id per client. The memory is bounded per client: a client that never pushes
224
+ * again keeps its last push's rejections. It assumes one journal per `clientId`,
225
+ * which backend contiguity already requires.
226
+ *
227
+ * Mutations from different client groups are serialisable (guarantee 1, #353),
228
+ * per dialect:
229
+ *
230
+ * - SQLite serialises transactions on its one connection, and the client takes
231
+ * the write lock with `BEGIN IMMEDIATE`, so a handler's reads and its writes
232
+ * never interleave with another group's transaction. It conforms.
233
+ * - PostgreSQL runs each `applyMutations` transaction at SERIALIZABLE: the
234
+ * dialect's `serializable` statement (`SET TRANSACTION ISOLATION LEVEL
235
+ * SERIALIZABLE`) runs first, before any read. It must be the transaction's
236
+ * first statement in a top-level transaction: PostgreSQL refuses it inside a
237
+ * subtransaction (a nested `transaction`, which a savepoint runs), so
238
+ * `applyMutations` then fails with a `BackendError` rather than running at
239
+ * READ COMMITTED. A transaction that read a row another transaction changed
240
+ * and committed first is refused at its write with SQLSTATE 40001, and rolls
241
+ * back. The executor reports that as a
242
+ * `BackendError`, which the client treats as transient and retries, and the
243
+ * retry runs the handler on the committed state. A handler therefore never
244
+ * commits a write computed from a stale read. This is not retried inside the
245
+ * backend: the client already re-sends an unacknowledged batch, so the
246
+ * transient path covers it without a second retry policy. A PostgreSQL
247
+ * executor that sets `transactional: false` loses this guarantee, since the
248
+ * ordering then has no transaction to isolate.
249
+ * - D1 is non-conforming (#342, #353). It has no interactive transactions, so its
250
+ * executor sets `transactional: false` and each statement commits on its own.
251
+ * `applyMutations` then keeps trunk's order: the handler runs, then the
252
+ * ledger is bumped to the larger of the stored and the new id, so an
253
+ * overlapping call that finishes late cannot move the watermark back. The
254
+ * handler's writes commit before the bump, so a pull between the two carries
255
+ * the effect without the watermark; a crash after the writes and before the
256
+ * bump makes the retry run the handler again; and a transient failure leaves
257
+ * the writes committed for the retry to repeat. A rejection recorded before a
258
+ * failed bump is forgotten when the retry applies the id (#425). Nothing
259
+ * isolates a handler's reads from another group's writes, so a lost update is
260
+ * possible. The claim order is not used on D1 because a claim committed on its
261
+ * own would leave a crash between the claim and the handler's commit claimed
262
+ * and unapplied, so a retry would skip the mutation (#359).
263
+ *
264
+ * An executor on another database must provide the same isolation, or the
265
+ * handlers' reads must be version-checked, before it can claim guarantee 1.
266
+ *
267
+ * A mapping's reads run against `readRelation` when given (issue #204): a
268
+ * real app's current-state relation is often a view — one that resolves each
269
+ * node's current version and joins the leaf at exactly that version — never
270
+ * the table mutator handlers write through. A mapping's `tombstone`
271
+ * predicate (the engine's own `Predicate` vocabulary, evaluated with
272
+ * `Predicate.test`) marks a row a soft delete rather than a live one: such a
273
+ * row is dropped from `evaluate`'s returned entries, so it simply isn't in
274
+ * the next CVR view and `Cvr.diff` reports it as a delete for that id,
275
+ * exactly as a row that vanished from the relation outright would (fail-open
276
+ * would be a bug: a soft-deleted row must never keep syncing as a put).
277
+ *
278
+ * A pull's subsets are evaluated together by `evaluateMany` (#505): one
279
+ * `UNION ALL` statement whose arms each select their subset's index and their
280
+ * row, plus one statement per `Exists` node, as in `evaluate`. On SQLite, whose
281
+ * `UNION ALL` columns take any type per row, an arm carries the relation's own
282
+ * columns by position, so each value reaches the backend exactly as `evaluate`
283
+ * reads it. On PostgreSQL an arm carries its row as JSON (`row_to_json`): the
284
+ * two agree wherever JSON carries a column's value as the driver does
285
+ * (`BackendContract` rule 14 checks it over the generated corpus), the README
286
+ * lists the column types it does not, and a pull whose rows hold a number
287
+ * JSON cannot carry exactly (an integer beyond ±(2^53 − 1), a `numeric` with
288
+ * more digits than a double, such a number inside a `jsonb` column) is
289
+ * answered by `evaluate` per subset instead, with an {@link InexactJsonNumber}
290
+ * warning naming the column. Integer, double and text values belong in typed
291
+ * columns (`integer`, `double precision`, `text`) rather than in JSON. The rows read back are finished
292
+ * by the same code as `evaluate`'s.
293
+ */
294
+ var SqlBackend_exports = /* @__PURE__ */ __exportAll({
295
+ InexactJsonNumber: () => InexactJsonNumber,
296
+ fromCollection: () => fromCollection,
297
+ make: () => make$2,
298
+ versionOf: () => versionOf
299
+ });
300
+ const decodeArgs = (definition, args, name) => Schema.decodeUnknownEffect(definition.args)(args).pipe(Effect.mapError((cause) => new BackendError({
301
+ message: `Invalid args for mutator "${name}"`,
302
+ cause
303
+ })));
304
+ /** The `rejections` column's value: a JSON object of mutation id → reason, or `null` when none is remembered. */
305
+ const encodeRejections = (rejections) => rejections.size === 0 ? null : JSON.stringify(Object.fromEntries(rejections));
306
+ /** Decode the `rejections` column (`null` and absent both mean none remembered). */
307
+ const decodeRejections = (text, mutationsTable) => {
308
+ if (text === null || text === void 0) return Effect.succeed(/* @__PURE__ */ new Map());
309
+ return Effect.try({
310
+ try: () => {
311
+ if (!P.isString(text)) throw new TypeError("not text");
312
+ return new Map(Object.entries(JSON.parse(text)).map(([id, reason]) => [Number(id), String(reason)]));
313
+ },
314
+ catch: (cause) => new BackendError({
315
+ message: `The "rejections" column of the mutation ledger "${mutationsTable}" holds a value that is not a rejection memory`,
316
+ cause
317
+ })
318
+ });
319
+ };
320
+ /**
321
+ * Derive a `TableMapping` from a `Collection`: column kinds come from its
322
+ * declared filterable set (`Collection.filterDeclarationOf`, #154) instead of
323
+ * being restated by hand — the common case. `table`, and optionally
324
+ * `readRelation` / `idColumn` / `versionColumn` / `tombstone`, are still
325
+ * given explicitly; the hand-written record shape (this function's return
326
+ * type) remains the override path for a mapping derivation can't cover.
327
+ */
328
+ const fromCollection = (collection, options) => {
329
+ const { filterable } = filterDeclarationOf(collection);
330
+ const kinds = Object.fromEntries(Object.entries(filterable).map(([field, declared]) => [field, declared.kind]));
331
+ return {
332
+ ...options,
333
+ kinds
334
+ };
335
+ };
336
+ /**
337
+ * The alias each arm of `evaluateMany`'s statement gives its relation: a
338
+ * `Json` dialect's `rowJson` names the row by it, so it must not be a column
339
+ * of any mapping.
340
+ */
341
+ const MANY_ROW_ALIAS = "__legation_row";
342
+ /** The subset index each row of `evaluateMany`'s statement carries. */
343
+ const ManyIndex = Schema.Struct({ subset: Schema.Int });
344
+ /** A row a `Json` dialect's arm carries: JSON text holding an object. */
345
+ const ManyJsonRow = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
346
+ /** A failure to decode a row of `evaluateMany`'s statement, as a `BackendError`. */
347
+ const undecodable = (cause) => new BackendError({
348
+ message: "evaluateMany read a row it could not decode",
349
+ cause
350
+ });
351
+ /** The subset index of a row of `evaluateMany`'s statement. */
352
+ const decodeManyIndex = (row) => Schema.decodeUnknownEffect(ManyIndex)(row).pipe(Effect.mapError(undecodable));
353
+ /** The row a `Json` dialect's arm carried, from its JSON text. */
354
+ const decodeManyJson = (text) => Schema.decodeEffect(ManyJsonRow)(text).pipe(Effect.mapError(undecodable));
355
+ /**
356
+ * Why a pull fell back from its batched statement to one `evaluate` per
357
+ * subset: a number in a JSON row (PostgreSQL's `row_to_json`) that JSON
358
+ * cannot carry exactly (#505). `JSON.parse` reads every JSON number as a
359
+ * double, so one with more significant digits than a double holds (a `bigint`
360
+ * past ±(2^53 − 1), a `numeric` of high precision, such a number inside a
361
+ * `json` / `jsonb` column) would read back as a different value than the
362
+ * database holds, and an integer past ±(2^53 − 1) is read by the driver as
363
+ * text, not a number. `evaluateMany` then answers the pull with `evaluate`
364
+ * per subset, whose values are the driver's, and logs this once per
365
+ * collection and column at `Warning` level, as the log's cause: declare the
366
+ * column `integer`, `double precision` or `text` in the read relation (a view
367
+ * can cast it) so the pull stays one statement.
368
+ *
369
+ * Logged, never raised: the pull still succeeds.
370
+ */
371
+ var InexactJsonNumber = class extends Schema.TaggedError()("InexactJsonNumber", {
372
+ message: Schema.String,
373
+ /** The collection whose read relation holds the column. */
374
+ collection: Schema.String,
375
+ /** The read relation's column the number sits in (top-level key of the row's JSON). */
376
+ column: Schema.String
377
+ }) {};
378
+ /**
379
+ * A JSON number's decimal value, as `<sign><digits>e<scale>` with the digits'
380
+ * leading and trailing zeros dropped (the value is `0.<digits> × 10^scale`),
381
+ * so two spellings of one value (`1.50`, `15e-1`) give the same key.
382
+ */
383
+ const decimalKey = (token) => {
384
+ const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
385
+ if (match === null) return token;
386
+ const [, sign = "", whole = "", fraction = "", exponent = "0"] = match;
387
+ const digits = `${whole}${fraction}`;
388
+ const significant = digits.replace(/^0+/, "");
389
+ const trimmed = significant.replace(/0+$/, "");
390
+ if (trimmed === "") return "0";
391
+ return `${sign}${trimmed}e${whole.length - (digits.length - significant.length) + Number(exponent)}`;
392
+ };
393
+ /**
394
+ * Whether `JSON.parse` reads a JSON number token as exactly the value it
395
+ * spells, and as a number the driver would also give: its decimal value equals
396
+ * the double it parses to, and that double is not an integer past
397
+ * ±(2^53 − 1) (which the driver reads as text).
398
+ */
399
+ const isExactJsonNumber = (token) => {
400
+ const value = Number(token);
401
+ return (!Number.isInteger(value) || Number.isSafeInteger(value)) && decimalKey(token) === decimalKey(String(value));
402
+ };
403
+ /** JSON's tokens, for {@link inexactColumns}: strings, numbers and punctuation. */
404
+ const JSON_TOKEN = /"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[{}[\]:,]/g;
405
+ /** How a JSON token changes the nesting depth: `{` / `[` open, `}` / `]` close. */
406
+ const nestingOf = (token) => {
407
+ if (token === "{" || token === "[") return 1;
408
+ return token === "}" || token === "]" ? -1 : 0;
409
+ };
410
+ /** Whether a JSON token is a number `JSON.parse` cannot carry exactly. */
411
+ const isInexactNumberToken = (token) => /^-?\d/.test(token) && !isExactJsonNumber(token);
412
+ /** A JSON string token's value (a column name, escapes decoded). */
413
+ const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String));
414
+ /**
415
+ * The columns of a JSON row whose values hold a number JSON cannot carry
416
+ * exactly (see {@link InexactJsonNumber}), nested values included: each
417
+ * distinct top-level key such a number sits under, in order.
418
+ */
419
+ const inexactColumns = (text) => {
420
+ const tokens = Array.from(text.matchAll(JSON_TOKEN), (match) => match[0]);
421
+ const [, found] = Arr.mapAccum(tokens, {
422
+ depth: 0,
423
+ key: "",
424
+ previous: ""
425
+ }, (state, token) => {
426
+ const depth = state.depth + nestingOf(token);
427
+ const key = token === ":" && state.depth === 1 ? Option.getOrElse(decodeJsonString(state.previous), () => state.previous) : state.key;
428
+ const inexact = isInexactNumberToken(token) ? Option.some(key) : Option.none();
429
+ return [{
430
+ depth,
431
+ key,
432
+ previous: token
433
+ }, inexact];
434
+ });
435
+ return Arr.dedupe(Arr.getSomes(found));
436
+ };
437
+ /** Content-hash fallback version when no version column is mapped. */
438
+ const versionOf = contentHash;
439
+ /**
440
+ * A {@link Backend.Backend} over a {@link SqlExecutor}: subset evaluation, row fetch and the
441
+ * transactional, idempotent mutation-id ledger.
442
+ */
443
+ const make$2 = (config) => {
444
+ const dialect = config.dialect ?? sqlite;
445
+ const executor = config.executor;
446
+ const transactional = executor.transactional ?? true;
447
+ const mutationsTable = config.mutationsTable ?? "sync_mutations";
448
+ const ph = (index) => dialect.placeholder(index);
449
+ const q = (id) => dialect.quote(id);
450
+ const idOf = (collection) => config.tables[collection]?.idColumn ?? "id";
451
+ const versionFor = (collection, row) => {
452
+ const column = config.tables[collection]?.versionColumn;
453
+ return column !== void 0 ? row[column] : versionOf(row);
454
+ };
455
+ const readTableOf = (mapping) => mapping.readRelation ?? mapping.table;
456
+ const isLive = (collection, value) => {
457
+ const tombstone = config.tables[collection]?.tombstone;
458
+ return tombstone === void 0 || !test(tombstone, value);
459
+ };
460
+ const foreignKinds = Object.fromEntries(Object.entries(config.tables).flatMap(([collection, mapping]) => mapping.kinds === void 0 ? [] : [[collection, mapping.kinds]]));
461
+ const booleanColumns = Object.fromEntries(Object.entries(config.tables).map(([collection, mapping]) => [collection, Object.entries(mapping.kinds ?? {}).filter(([, kind]) => kind === "boolean").map(([field]) => field)]));
462
+ const decodeRow = (collection, row) => {
463
+ let decoded;
464
+ Arr.forEach(booleanColumns[collection] ?? [], (field) => {
465
+ const value = row[field];
466
+ if (value !== 0 && value !== 1) return;
467
+ decoded ??= { ...row };
468
+ decoded[field] = value === 1;
469
+ });
470
+ return decoded ?? row;
471
+ };
472
+ const resolveExistsNode = (node) => {
473
+ const mapping = config.tables[node.collection];
474
+ if (mapping === void 0) return Effect.succeed([]);
475
+ const where = compileWhere(node.where, dialect, {
476
+ kinds: mapping.kinds,
477
+ foreignKinds
478
+ });
479
+ return executor.query({
480
+ text: `SELECT DISTINCT ${q(node.foreignField)} FROM ${q(readTableOf(mapping))} WHERE ${where.text}`,
481
+ params: where.params
482
+ }).pipe(Effect.map((rows) => rows.flatMap((row) => {
483
+ const decoded = decodeRow(node.collection, row);
484
+ if (!isLive(node.collection, decoded)) return [];
485
+ const value = decoded[node.foreignField];
486
+ return isNullish(value) ? [] : [value];
487
+ })));
488
+ };
489
+ const entriesOf = (subset, where, rows) => {
490
+ const idColumn = idOf(subset.collection);
491
+ const values = rows.map((row) => ({
492
+ id: String(row[idColumn]),
493
+ value: decodeRow(subset.collection, row)
494
+ })).filter((row) => isLive(subset.collection, row.value));
495
+ return apply(values, {
496
+ ...subset,
497
+ where
498
+ }).map((row) => ({
499
+ id: row.id,
500
+ version: versionFor(subset.collection, row.value)
501
+ }));
502
+ };
503
+ const evaluate = (_context, subset) => Effect.gen(function* () {
504
+ const mapping = config.tables[subset.collection];
505
+ if (mapping === void 0) return [];
506
+ const where = yield* resolveExists(subset.where, resolveExistsNode);
507
+ const rows = yield* executor.query(compileSelect({
508
+ table: readTableOf(mapping),
509
+ where,
510
+ dialect,
511
+ kinds: mapping.kinds,
512
+ foreignKinds
513
+ }));
514
+ return entriesOf(subset, where, rows);
515
+ });
516
+ const columnCache = MutableHashMap.empty();
517
+ const columnsOf = (union, relation) => {
518
+ if (P.isTagged(union, "Json")) return Effect.succeed([]);
519
+ return Option.match(MutableHashMap.get(columnCache, relation), {
520
+ onSome: Effect.succeed,
521
+ onNone: () => executor.query(union.columnsOf(relation)).pipe(Effect.map((rows) => rows.map((row) => String(row["name"]))), Effect.tap((columns) => Effect.sync(() => {
522
+ if (Arr.isReadonlyArrayNonEmpty(columns)) MutableHashMap.set(columnCache, relation, columns);
523
+ })))
524
+ });
525
+ };
526
+ const manyArm = (union) => (subset, index) => {
527
+ const mapping = config.tables[subset.collection];
528
+ if (mapping === void 0) return Effect.succeedNone;
529
+ return Effect.all({
530
+ where: resolveExists(subset.where, resolveExistsNode),
531
+ columns: columnsOf(union, readTableOf(mapping))
532
+ }, { concurrency: 1 }).pipe(Effect.map(({ where, columns }) => Option.some({
533
+ index,
534
+ subset,
535
+ mapping,
536
+ where,
537
+ columns
538
+ })));
539
+ };
540
+ const projection = (union, arm, width) => {
541
+ const alias = q(MANY_ROW_ALIAS);
542
+ if (P.isTagged(union, "Json")) return `${union.rowJson(alias)} AS ${q("row")}`;
543
+ return Arr.makeBy(width, (position) => {
544
+ const column = arm.columns[position];
545
+ return `${column === void 0 ? "NULL" : `${alias}.${q(column)}`} AS ${q(`c${position}`)}`;
546
+ }).join(", ");
547
+ };
548
+ const readManyRow = (union, arms) => (row) => Effect.gen(function* () {
549
+ const { subset } = yield* decodeManyIndex(row);
550
+ if (P.isTagged(union, "Json")) {
551
+ const text = String(row["row"]);
552
+ return {
553
+ subset,
554
+ row: yield* decodeManyJson(text),
555
+ inexact: inexactColumns(text)
556
+ };
557
+ }
558
+ const columns = Option.match(arms[subset] ?? Option.none(), {
559
+ onNone: () => [],
560
+ onSome: (arm) => arm.columns
561
+ });
562
+ return {
563
+ subset,
564
+ row: R.fromEntries(columns.map((name, position) => [name, row[`c${position}`]])),
565
+ inexact: Arr.empty()
566
+ };
567
+ });
568
+ const warnedInexact = MutableHashSet.empty();
569
+ const warnInexact = ({ collection, column }) => Effect.suspend(() => {
570
+ const key = `${collection}\u0000${column}`;
571
+ if (MutableHashSet.has(warnedInexact, key)) return Effect.void;
572
+ MutableHashSet.add(warnedInexact, key);
573
+ const message = `evaluateMany: column "${column}" of collection "${collection}" holds a number JSON cannot carry exactly, so the pull falls back to one evaluate per subset; declare it integer, double precision or text in the read relation`;
574
+ return Effect.logWarning(message, new InexactJsonNumber({
575
+ message,
576
+ collection,
577
+ column
578
+ }));
579
+ });
580
+ const evaluateMany = (union) => ((context, subsets) => Effect.gen(function* () {
581
+ const arms = yield* Effect.forEach(subsets, manyArm(union), { concurrency: 1 });
582
+ const present = Arr.getSomes(arms);
583
+ const width = Math.max(0, ...present.map((arm) => arm.columns.length));
584
+ const [params, texts] = Arr.mapAccum(present, [], (bound, arm) => {
585
+ const offset = bound.length;
586
+ const compiled = compileWhere(arm.where, {
587
+ ...dialect,
588
+ placeholder: (bind) => dialect.placeholder(offset + bind)
589
+ }, {
590
+ kinds: arm.mapping.kinds,
591
+ foreignKinds
592
+ });
593
+ const relation = q(readTableOf(arm.mapping));
594
+ return [[...bound, ...compiled.params], `SELECT ${arm.index} AS ${q("subset")}, ${projection(union, arm, width)} FROM ${relation} AS ${q(MANY_ROW_ALIAS)} WHERE ${compiled.text}`];
595
+ });
596
+ const rows = Arr.isReadonlyArrayNonEmpty(texts) ? yield* executor.query({
597
+ text: texts.join(" UNION ALL "),
598
+ params
599
+ }) : [];
600
+ const read = yield* Effect.forEach(rows, readManyRow(union, arms), { concurrency: 1 });
601
+ const inexact = read.flatMap((row) => row.inexact.map((column) => ({
602
+ collection: subsets[row.subset]?.collection ?? "",
603
+ column
604
+ })));
605
+ if (Arr.isReadonlyArrayNonEmpty(inexact)) {
606
+ yield* Effect.forEach(inexact, warnInexact, {
607
+ concurrency: 1,
608
+ discard: true
609
+ });
610
+ return yield* Effect.forEach(subsets, (subset) => evaluate(context, subset), { concurrency: 1 });
611
+ }
612
+ const bySubset = Arr.groupBy(read, (row) => String(row.subset));
613
+ return arms.map((arm) => Option.match(arm, {
614
+ onNone: () => [],
615
+ onSome: ({ index, subset, where }) => entriesOf(subset, where, (bySubset[String(index)] ?? []).map((row) => row.row))
616
+ }));
617
+ }));
618
+ const queryRowsById = (collection, ids) => {
619
+ const mapping = config.tables[collection];
620
+ if (mapping === void 0 || Arr.isReadonlyArrayEmpty(ids)) return Effect.succeed([]);
621
+ return executor.query(compileSelect({
622
+ table: readTableOf(mapping),
623
+ where: isIn(idOf(collection), [...ids]),
624
+ dialect,
625
+ kinds: mapping.kinds
626
+ })).pipe(Effect.map((rows) => rows.map((row) => decodeRow(collection, row)).filter((row) => isLive(collection, row))));
627
+ };
628
+ const getRows = (_context, collection, ids) => queryRowsById(collection, ids).pipe(Effect.map((rows) => rows.map((row) => ({
629
+ id: String(row[idOf(collection)]),
630
+ version: versionFor(collection, row),
631
+ value: row
632
+ }))));
633
+ const rowValuesFor = (collection, ids) => queryRowsById(collection, ids).pipe(Effect.map((rows) => rows.map((row) => ({
634
+ id: String(row[idOf(collection)]),
635
+ value: row
636
+ }))));
637
+ const enrichHint = (hint) => config.tables[hint.collection] === void 0 ? Effect.succeed(hint) : rowValuesFor(hint.collection, hint.ids).pipe(Effect.map((values) => ({
638
+ ...hint,
639
+ values
640
+ })));
641
+ const readLastIds = (clientGroupId) => executor.query({
642
+ text: `SELECT ${q("client_id")}, ${q("last_mutation_id")} FROM ${q(mutationsTable)} WHERE ${q("client_group_id")} = ${ph(1)}`,
643
+ params: [clientGroupId]
644
+ }).pipe(Effect.map((rows) => Object.fromEntries(rows.map((row) => [String(row.client_id), Number(row.last_mutation_id)]))));
645
+ const readLedger = (clientGroupId) => executor.query({
646
+ text: `SELECT ${q("client_id")}, ${q("last_mutation_id")}, ${q("rejections")} FROM ${q(mutationsTable)} WHERE ${q("client_group_id")} = ${ph(1)}`,
647
+ params: [clientGroupId]
648
+ }).pipe(Effect.mapError((cause) => new BackendError({
649
+ message: `The mutation ledger "${mutationsTable}" could not be read with its "rejections" column. If the column is missing, check for it, then run \`ALTER TABLE ${mutationsTable} ADD COLUMN rejections TEXT\` (the sql README's "Ledger without the \`rejections\` column" section).`,
650
+ cause
651
+ })), Effect.flatMap((rows) => Effect.forEach(rows, (row) => decodeRejections(row.rejections, mutationsTable).pipe(Effect.map((rejections) => [String(row.client_id), {
652
+ last: Number(row.last_mutation_id),
653
+ rejections
654
+ }])))), Effect.map((entries) => new Map(entries)));
655
+ const writeRejections = (clientGroupId, clientId, rejections) => executor.run({
656
+ text: `UPDATE ${q(mutationsTable)} SET ${q("rejections")} = ${ph(1)} WHERE ${q("client_group_id")} = ${ph(2)} AND ${q("client_id")} = ${ph(3)}`,
657
+ params: [
658
+ encodeRejections(rejections),
659
+ clientGroupId,
660
+ clientId
661
+ ]
662
+ });
663
+ const recordRejection = (clientGroupId, clientId, mutationId, reason) => Effect.gen(function* () {
664
+ yield* ensureLedgerRow(clientGroupId, clientId);
665
+ const row = (yield* readLedger(clientGroupId)).get(clientId);
666
+ const rejections = new Map(row?.rejections ?? []);
667
+ rejections.set(mutationId, reason);
668
+ yield* writeRejections(clientGroupId, clientId, rejections);
669
+ });
670
+ const forgetRejections = (clientGroupId, floors) => Effect.gen(function* () {
671
+ if (floors.size === 0) return;
672
+ const ledger = yield* readLedger(clientGroupId);
673
+ yield* Effect.forEach(floors, ([clientId, floor]) => Effect.gen(function* () {
674
+ const row = ledger.get(clientId);
675
+ if (row === void 0 || row.rejections.size === 0) return;
676
+ const kept = new Map([...row.rejections].filter(([mutationId]) => mutationId >= floor));
677
+ if (kept.size !== row.rejections.size) yield* writeRejections(clientGroupId, clientId, kept);
678
+ }), {
679
+ concurrency: 1,
680
+ discard: true
681
+ });
682
+ });
683
+ const ensureLedgerRow = (clientGroupId, clientId) => executor.run({
684
+ text: `INSERT INTO ${q(mutationsTable)} (${q("client_group_id")}, ${q("client_id")}, ${q("last_mutation_id")}) VALUES (${ph(1)}, ${ph(2)}, 0) ON CONFLICT (${q("client_group_id")}, ${q("client_id")}) DO NOTHING`,
685
+ params: [clientGroupId, clientId]
686
+ });
687
+ const claimLedger = (clientGroupId, clientId, mutationId) => executor.query({
688
+ text: `UPDATE ${q(mutationsTable)} SET ${q("last_mutation_id")} = ${ph(1)} WHERE ${q("client_group_id")} = ${ph(2)} AND ${q("client_id")} = ${ph(3)} AND ${q("last_mutation_id")} = ${ph(4)} RETURNING ${q("last_mutation_id")}`,
689
+ params: [
690
+ mutationId,
691
+ clientGroupId,
692
+ clientId,
693
+ mutationId - 1
694
+ ]
695
+ }).pipe(Effect.map((rows) => Arr.isReadonlyArrayNonEmpty(rows)));
696
+ const unitOf = (batch, mutation, staleRejection) => {
697
+ const savepoint = q(`legation_sp_${batch.savepoints.next}`);
698
+ batch.savepoints.next += 1;
699
+ return {
700
+ context: batch.context,
701
+ mutation,
702
+ savepoint,
703
+ staleRejection
704
+ };
705
+ };
706
+ const openClaimFirst = (batch, mutation) => Effect.gen(function* () {
707
+ const { clientGroupId } = batch.context;
708
+ yield* ensureLedgerRow(clientGroupId, mutation.clientId);
709
+ if (yield* claimLedger(clientGroupId, mutation.clientId, mutation.mutationId)) return {
710
+ _tag: "Opened",
711
+ unit: unitOf(batch, mutation, false)
712
+ };
713
+ const row = (yield* readLedger(clientGroupId)).get(mutation.clientId);
714
+ return {
715
+ _tag: "Watermark",
716
+ lastMutationId: row?.last ?? 0,
717
+ rejection: Option.fromUndefinedOr(row?.rejections.get(mutation.mutationId))
718
+ };
719
+ });
720
+ const openBumpAfter = (batch, mutation) => readLedger(batch.context.clientGroupId).pipe(Effect.map((ledger) => {
721
+ const row = ledger.get(mutation.clientId);
722
+ const last = row?.last ?? 0;
723
+ return mutation.mutationId === last + 1 ? {
724
+ _tag: "Opened",
725
+ unit: unitOf(batch, mutation, row?.rejections.has(mutation.mutationId) === true)
726
+ } : {
727
+ _tag: "Watermark",
728
+ lastMutationId: last,
729
+ rejection: Option.fromUndefinedOr(row?.rejections.get(mutation.mutationId))
730
+ };
731
+ }));
732
+ const bumpLastId = (clientGroupId, clientId, mutationId) => executor.run({
733
+ text: `INSERT INTO ${q(mutationsTable)} (${q("client_group_id")}, ${q("client_id")}, ${q("last_mutation_id")}) VALUES (${ph(1)}, ${ph(2)}, ${ph(3)}) ON CONFLICT (${q("client_group_id")}, ${q("client_id")}) DO UPDATE SET ${q("last_mutation_id")} = ` + dialect.greatest(`${q(mutationsTable)}.${q("last_mutation_id")}`, `excluded.${q("last_mutation_id")}`),
734
+ params: [
735
+ clientGroupId,
736
+ clientId,
737
+ mutationId
738
+ ]
739
+ });
740
+ const releaseClaim = (clientGroupId, clientId, mutationId) => executor.run({
741
+ text: `UPDATE ${q(mutationsTable)} SET ${q("last_mutation_id")} = ${ph(1)} WHERE ${q("client_group_id")} = ${ph(2)} AND ${q("client_id")} = ${ph(3)} AND ${q("last_mutation_id")} = ${ph(4)}`,
742
+ params: [
743
+ mutationId - 1,
744
+ clientGroupId,
745
+ clientId,
746
+ mutationId
747
+ ]
748
+ }).pipe(Effect.ignore);
749
+ const runHandler = (unit, handler, mutation) => Effect.gen(function* () {
750
+ const definition = config.mutators.get(mutation.mutator);
751
+ if (definition === void 0) return yield* make$3(mutation.mutator);
752
+ const args = yield* decodeArgs(definition, mutation.args, mutation.mutator);
753
+ yield* executor.run({
754
+ text: `SAVEPOINT ${unit.savepoint}`,
755
+ params: []
756
+ });
757
+ return yield* handler(args, executor, unit.context);
758
+ });
759
+ const commitOutcome = (unit, outcome) => Effect.gen(function* () {
760
+ const { clientGroupId } = unit.context;
761
+ const { clientId, mutationId } = unit.mutation;
762
+ if (outcome._tag === "Rejected") {
763
+ yield* executor.run({
764
+ text: `ROLLBACK TO SAVEPOINT ${unit.savepoint}`,
765
+ params: []
766
+ });
767
+ yield* executor.run({
768
+ text: `RELEASE SAVEPOINT ${unit.savepoint}`,
769
+ params: []
770
+ });
771
+ yield* recordRejection(clientGroupId, clientId, mutationId, outcome.reason);
772
+ } else {
773
+ yield* executor.run({
774
+ text: `RELEASE SAVEPOINT ${unit.savepoint}`,
775
+ params: []
776
+ });
777
+ if (!transactional && unit.staleRejection) {
778
+ const row = (yield* readLedger(clientGroupId)).get(clientId);
779
+ const rejections = new Map(row?.rejections ?? []);
780
+ rejections.delete(mutationId);
781
+ yield* writeRejections(clientGroupId, clientId, rejections);
782
+ }
783
+ }
784
+ if (!transactional) yield* bumpLastId(clientGroupId, clientId, mutationId);
785
+ return { _tag: "Committed" };
786
+ });
787
+ const serializable = transactional ? dialect.serializable : void 0;
788
+ const atomically = (_context, effect) => executor.transaction(serializable === void 0 ? effect : executor.run({
789
+ text: serializable,
790
+ params: []
791
+ }).pipe(Effect.mapError((cause) => new BackendError({
792
+ message: "SERIALIZABLE isolation could not be set for this mutation batch. On PostgreSQL the executor's transaction must be top-level, with this statement first: SET TRANSACTION fails inside a nested transaction (a savepoint) or after another statement.",
793
+ cause
794
+ })), Effect.andThen(effect)));
795
+ const applyMutations = transaction({
796
+ handlers: config.handlers,
797
+ atomically,
798
+ maxAttempts: 1,
799
+ begin: (context, floors) => forgetRejections(context.clientGroupId, floors).pipe(Effect.as({
800
+ context,
801
+ savepoints: { next: 0 }
802
+ })),
803
+ open: transactional ? openClaimFirst : openBumpAfter,
804
+ run: runHandler,
805
+ commit: commitOutcome,
806
+ release: transactional ? (unit) => releaseClaim(unit.context.clientGroupId, unit.mutation.clientId, unit.mutation.mutationId) : () => Effect.void,
807
+ finish: ({ context }, applied) => Effect.all({
808
+ lastMutationIds: readLastIds(context.clientGroupId),
809
+ hints: Effect.forEach(applied.flat(), enrichHint)
810
+ }, { concurrency: 1 })
811
+ });
812
+ const rememberedRejections = (clientGroupId) => readLedger(clientGroupId).pipe(Effect.map((ledger) => [...ledger].flatMap(([clientId, row]) => [...row.rejections].map(([mutationId, reason]) => ({
813
+ clientId,
814
+ mutationId,
815
+ reason
816
+ })))));
817
+ return Backend.of({
818
+ applyMutations,
819
+ lastMutationIds: (context) => readLastIds(context.clientGroupId),
820
+ rejections: (context) => rememberedRejections(context.clientGroupId),
821
+ evaluate,
822
+ evaluateMany: dialect.unionRows === void 0 ? void 0 : evaluateMany(dialect.unionRows),
823
+ getRows
824
+ });
825
+ };
826
+ // src/sql/PollingHintSource.ts
827
+ /**
828
+ * PollingHintSource
829
+ *
830
+ * A tier-2 change-data-capture hint source (ADR-0019): a scoped fibre polls
831
+ * each configured table on an interval for a cheap change-detector and, when a
832
+ * table's detector moves between polls, publishes a value-less `ChangeHint` for
833
+ * that collection to the InvalidationBus. Out-of-band writes (cron jobs, admin
834
+ * tools, another service, psql) that never went through a mutator therefore
835
+ * still poke subscribed clients, at a bounded staleness of one poll interval.
836
+ *
837
+ * It uses only the existing `SqlExecutor` seam — no new driver dependency
838
+ * (ADR-0006) — so any backend that can already run the `SqlBackend` can also run
839
+ * this.
840
+ *
841
+ * The published hint is value-less (`ids: []`), so the #42 fan-out matcher falls
842
+ * back to a collection-level match: every group subscribed to the collection is
843
+ * poked and the next pull reconciles. That is sound — over-matching a poke costs
844
+ * an empty pull; poke is never a correctness dependency (ADR-0009).
845
+ *
846
+ * Detector coverage (be honest about it): the detector is
847
+ * `SELECT COUNT(*) AS n, MAX(version) AS v` when a `versionColumn` is mapped,
848
+ * else `SELECT COUNT(*) AS n`. `COUNT(*) + MAX(version)` catches inserts,
849
+ * deletes, and version-bumping updates. A table with NO monotonic
850
+ * `versionColumn` can only detect row-count changes: an in-place update that
851
+ * keeps the row count and has no version column is missed. Map a `versionColumn`
852
+ * (or an `updated_at`) for full coverage.
853
+ */
854
+ var PollingHintSource_exports = /* @__PURE__ */ __exportAll({ make: () => make$1 });
855
+ const quote = sqlite.quote;
856
+ /**
857
+ * The cheap change-detector query for a table: count plus (if a version column
858
+ * is mapped) its running maximum.
859
+ */
860
+ const detectorQuery = (mapping) => {
861
+ const table = quote(mapping.table);
862
+ return mapping.versionColumn !== void 0 ? {
863
+ text: `SELECT COUNT(*) AS n, MAX(${quote(mapping.versionColumn)}) AS v FROM ${table}`,
864
+ params: []
865
+ } : {
866
+ text: `SELECT COUNT(*) AS n FROM ${table}`,
867
+ params: []
868
+ };
869
+ };
870
+ /** Collapse a detector row to a comparable signature `${count}|${maxVersion}`. */
871
+ const signatureOf = (rows) => {
872
+ const row = rows[0] ?? {};
873
+ const version = row.v === void 0 || row.v === null ? "" : String(row.v);
874
+ return `${String(row.n ?? 0)}|${version}`;
875
+ };
876
+ /**
877
+ * A `HintSource` that polls each mapped table for a cheap change detector and publishes a hint
878
+ * when a collection changes out of band.
879
+ */
880
+ const make$1 = (config) => {
881
+ const collections = config.collections ?? Object.keys(config.tables);
882
+ const signatureFor = (collection) => {
883
+ const mapping = config.tables[collection];
884
+ if (mapping === void 0) return Effect.succeed(void 0);
885
+ return config.executor.query(detectorQuery(mapping)).pipe(Effect.map(signatureOf));
886
+ };
887
+ const tick = (bus, state) => Effect.gen(function* () {
888
+ const previous = yield* Ref.get(state);
889
+ const next = new Map(previous);
890
+ const changed = [];
891
+ yield* Effect.forEach(collections, (collection) => Effect.gen(function* () {
892
+ const signature = yield* signatureFor(collection);
893
+ if (signature === void 0) return;
894
+ if (previous.has(collection) && previous.get(collection) !== signature) changed.push({
895
+ collection,
896
+ ids: []
897
+ });
898
+ next.set(collection, signature);
899
+ }), {
900
+ concurrency: 1,
901
+ discard: true
902
+ });
903
+ yield* Ref.set(state, next);
904
+ yield* bus.publish(changed);
905
+ });
906
+ return { run: Effect.flatMap(InvalidationBus, (bus) => Effect.gen(function* () {
907
+ const state = yield* Ref.make(/* @__PURE__ */ new Map());
908
+ yield* Effect.forkScoped(tick(bus, state).pipe(Effect.catch(() => Effect.void), Effect.repeat(Schedule.spaced(config.interval))));
909
+ })) };
910
+ };
911
+ // src/sql/PgExecutor.ts
912
+ /**
913
+ * PgExecutor
914
+ *
915
+ * A `SqlBackend` executor over a PostgreSQL `SqlClient` from `effect/sql`
916
+ * (#203). Pair it with `dialect: Sql.postgres`:
917
+ *
918
+ * ```ts
919
+ * const sql = yield* SqlClient; // from `PgClient.layer(...)` (`@effect/sql-pg`)
920
+ * const backend = SqlBackend.make({
921
+ * executor: PgExecutor.make(sql),
922
+ * dialect: Sql.postgres,
923
+ * tables,
924
+ * mutators,
925
+ * handlers,
926
+ * });
927
+ * ```
928
+ *
929
+ * The executor needs only the `SqlClient` service, which ships in `effect`
930
+ * itself, so this package gains no dependency (ADR-0006): the driver
931
+ * (`@effect/sql-pg`) and its connection settings are the app's choice, and it
932
+ * provides the `SqlClient` layer.
933
+ *
934
+ * **Transactions:** `transaction` is the client's `withTransaction`, so each
935
+ * mutation batch is one real PostgreSQL transaction on one connection, and
936
+ * `SqlBackend` claims each mutation id in the same transaction as its handler's
937
+ * writes. `Sql.postgres` sets SERIALIZABLE as the transaction's first
938
+ * statement, so a handler that read a row another transaction then changed is
939
+ * refused with SQLSTATE 40001 and rolled back; that surfaces as a
940
+ * `BackendError`, which the client retries. The transaction must be top-level:
941
+ * calling the backend inside an outer `withTransaction` makes this a savepoint,
942
+ * where PostgreSQL refuses `SET TRANSACTION` and the batch fails instead.
943
+ *
944
+ * **Statements run unprepared** (`.unprepared`): each is parsed per call rather
945
+ * than cached by name on the connection. A transaction-mode pooler in front of
946
+ * the database (PgBouncer, a managed pooler) can hand consecutive statements to
947
+ * different server connections, where a named statement prepared on one does not
948
+ * exist on the next. The cost is a parse per statement.
949
+ */
950
+ var PgExecutor_exports = /* @__PURE__ */ __exportAll({ make: () => make });
951
+ const asBackendError = (message) => (cause) => new BackendError({
952
+ message,
953
+ cause
954
+ });
955
+ /**
956
+ * The executor over `sql`: unprepared statements, and a real transaction per `transaction` call.
957
+ *
958
+ * A failed statement or transaction fails as a `BackendError` that carries the driver's `SqlError`
959
+ * as its `cause` (a serialization refusal included), with the statement text, never its parameters,
960
+ * in the message.
961
+ */
962
+ const make = (sql) => ({
963
+ query: (fragment) => sql.unsafe(fragment.text, fragment.params).unprepared.pipe(Effect.mapError(asBackendError(`query failed: ${fragment.text}`))),
964
+ run: (fragment) => sql.unsafe(fragment.text, fragment.params).unprepared.pipe(Effect.asVoid, Effect.mapError(asBackendError(`statement failed: ${fragment.text}`))),
965
+ transaction: (effect) => sql.withTransaction(effect).pipe(Effect.catchTag("SqlError", (error) => Effect.fail(asBackendError("transaction failed")(error))))
966
+ });
967
+ export { PgExecutor_exports as PgExecutor, PollingHintSource_exports as PollingHintSource, Sql_exports as Sql, SqlBackend_exports as SqlBackend };
968
+
969
+ //# sourceMappingURL=index.js.map