@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.
- package/CHANGELOG.md +2054 -0
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/dist/MemoryBackend-BziXwfcs.d.ts +4884 -0
- package/dist/MemoryBackend-BziXwfcs.d.ts.map +1 -0
- package/dist/MemoryBackend-DxP8t4ge.js +9477 -0
- package/dist/MemoryBackend-DxP8t4ge.js.map +1 -0
- package/dist/index-CPPfp0F4.d.ts +399 -0
- package/dist/index-CPPfp0F4.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/internal-CxXRlFK9.d.ts +2362 -0
- package/dist/internal-CxXRlFK9.d.ts.map +1 -0
- package/dist/internal-JyoQMlfK.js +3758 -0
- package/dist/internal-JyoQMlfK.js.map +1 -0
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +3 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/dist/sql/index.d.ts +294 -0
- package/dist/sql/index.d.ts.map +1 -0
- package/dist/sql/index.js +969 -0
- package/dist/sql/index.js.map +1 -0
- package/dist/src-FWXpu_vP.js +1082 -0
- package/dist/src-FWXpu_vP.js.map +1 -0
- package/package.json +61 -3
|
@@ -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
|