snapback4 0.0.1 → 0.0.3
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/README.md +360 -15
- package/bin/snapback4.js +24 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +90 -0
- package/dist/client.d.ts +18 -0
- package/dist/client.js +262 -0
- package/dist/local.d.ts +34 -0
- package/dist/local.js +358 -0
- package/dist/replica/cursor.d.ts +15 -0
- package/dist/replica/cursor.js +81 -0
- package/dist/replica/index.d.ts +3 -0
- package/dist/replica/index.js +6 -0
- package/dist/replica/interpreter.d.ts +106 -0
- package/dist/replica/interpreter.js +906 -0
- package/dist/replica/key.d.ts +9 -0
- package/dist/replica/key.js +107 -0
- package/dist/replica/replica.d.ts +58 -0
- package/dist/replica/replica.js +147 -0
- package/dist/replica/sqlite.d.ts +29 -0
- package/dist/replica/sqlite.js +151 -0
- package/dist/replica/store.d.ts +91 -0
- package/dist/replica/store.js +359 -0
- package/package.json +29 -6
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
// The interpreter on the device: the same IR the server runs, over the
|
|
2
|
+
// replica store, with the same meter and the same cursors, so a query is
|
|
3
|
+
// `complete` inside the horizon and a predictable mutation runs locally
|
|
4
|
+
// first (LLP 2000.000 §7.2, §7.3). Values are plain JSON here; the server's
|
|
5
|
+
// tagged literals are untagged as they are read.
|
|
6
|
+
import { canonicalJson, decodeCursor, encodeCursor } from "./cursor.js";
|
|
7
|
+
export class Refused extends Error {
|
|
8
|
+
refusal;
|
|
9
|
+
constructor(refusal) {
|
|
10
|
+
super(`${refusal.code}: ${refusal.message}`);
|
|
11
|
+
this.refusal = refusal;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const CEILINGS = { examined: 8192, ruleProbes: 8192, steps: 1_000_000, rowsWritten: 1024 };
|
|
15
|
+
const untagged = (v) => {
|
|
16
|
+
if (v === null || typeof v !== "object")
|
|
17
|
+
return v;
|
|
18
|
+
if (Array.isArray(v))
|
|
19
|
+
return v.map(untagged);
|
|
20
|
+
const entries = Object.entries(v);
|
|
21
|
+
if (entries.length === 1) {
|
|
22
|
+
const [tag, inner] = entries[0];
|
|
23
|
+
switch (tag) {
|
|
24
|
+
case "Null": return null;
|
|
25
|
+
case "Bool":
|
|
26
|
+
case "Int":
|
|
27
|
+
case "String":
|
|
28
|
+
case "Id":
|
|
29
|
+
case "Bytes": return inner;
|
|
30
|
+
case "Array": return inner.map(untagged);
|
|
31
|
+
case "Row":
|
|
32
|
+
case "Map": return Object.fromEntries(Object.entries(inner).map(([k, x]) => [k, untagged(x)]));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return v;
|
|
36
|
+
};
|
|
37
|
+
/** A tagged literal from the IR as a plain value. */
|
|
38
|
+
/** Every `.field` a rule reads, on any probe key or comparison. */
|
|
39
|
+
function ruleFields(rule, out) {
|
|
40
|
+
if (typeof rule !== "object" || rule === null)
|
|
41
|
+
return;
|
|
42
|
+
const [kind, body] = Object.entries(rule)[0];
|
|
43
|
+
const term = (t) => { if (typeof t === "object" && t !== null && "Field" in t)
|
|
44
|
+
out.add(t.Field); };
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case "Eq":
|
|
47
|
+
case "Ne":
|
|
48
|
+
body.forEach(term);
|
|
49
|
+
break;
|
|
50
|
+
case "And":
|
|
51
|
+
case "Or":
|
|
52
|
+
body.forEach((r) => ruleFields(r, out));
|
|
53
|
+
break;
|
|
54
|
+
case "Not":
|
|
55
|
+
ruleFields(body, out);
|
|
56
|
+
break;
|
|
57
|
+
case "Exists":
|
|
58
|
+
case "Created":
|
|
59
|
+
body.key.forEach(term);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Whether a rule term is pinned by a scan's prefix expression. */
|
|
64
|
+
function pinnedTerm(term, expression) {
|
|
65
|
+
if (expression === undefined)
|
|
66
|
+
return false;
|
|
67
|
+
if (term === "Viewer")
|
|
68
|
+
return typeof expression === "object" && expression !== null && expression.Input === "Viewer";
|
|
69
|
+
if (typeof term === "object" && term !== null && "Literal" in term)
|
|
70
|
+
return typeof expression === "object" && expression !== null && "Literal" in expression && canonicalJson(term.Literal) === canonicalJson(expression.Literal);
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
export function literal(v) {
|
|
74
|
+
if (v === "Null")
|
|
75
|
+
return null;
|
|
76
|
+
return untagged(v);
|
|
77
|
+
}
|
|
78
|
+
const tagOf = (v) => v === null || v === undefined ? 0 : typeof v === "boolean" ? 1 : typeof v === "number" ? 2 : typeof v === "string" ? 3 : Array.isArray(v) ? 7 : 6;
|
|
79
|
+
/** The value model's total order over plain values. */
|
|
80
|
+
export function totalCompare(a, b) {
|
|
81
|
+
const ta = tagOf(a), tb = tagOf(b);
|
|
82
|
+
if (ta !== tb)
|
|
83
|
+
return ta < tb ? -1 : 1;
|
|
84
|
+
switch (ta) {
|
|
85
|
+
case 0: return 0;
|
|
86
|
+
case 1: return a === b ? 0 : a ? 1 : -1;
|
|
87
|
+
case 2: return a < b ? -1 : a === b ? 0 : 1;
|
|
88
|
+
case 3: return a < b ? -1 : a === b ? 0 : 1;
|
|
89
|
+
case 7: {
|
|
90
|
+
const x = a, y = b;
|
|
91
|
+
for (let i = 0; i < Math.min(x.length, y.length); i++) {
|
|
92
|
+
const c = totalCompare(x[i], y[i]);
|
|
93
|
+
if (c !== 0)
|
|
94
|
+
return c;
|
|
95
|
+
}
|
|
96
|
+
return x.length - y.length;
|
|
97
|
+
}
|
|
98
|
+
default: {
|
|
99
|
+
const x = Object.entries(a).sort(), y = Object.entries(b).sort();
|
|
100
|
+
if (x.length !== y.length)
|
|
101
|
+
return x.length - y.length;
|
|
102
|
+
for (let i = 0; i < x.length; i++) {
|
|
103
|
+
if (x[i][0] !== y[i][0])
|
|
104
|
+
return x[i][0] < y[i][0] ? -1 : 1;
|
|
105
|
+
const c = totalCompare(x[i][1], y[i][1]);
|
|
106
|
+
if (c !== 0)
|
|
107
|
+
return c;
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function equal(a, b) {
|
|
114
|
+
return totalCompare(a, b) === 0;
|
|
115
|
+
}
|
|
116
|
+
class Flow {
|
|
117
|
+
kind;
|
|
118
|
+
value;
|
|
119
|
+
constructor(kind, value) {
|
|
120
|
+
this.kind = kind;
|
|
121
|
+
this.value = value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Runs programs over one store transaction. */
|
|
125
|
+
export class Interpreter {
|
|
126
|
+
tx;
|
|
127
|
+
schema;
|
|
128
|
+
ctx;
|
|
129
|
+
mode;
|
|
130
|
+
ceilings;
|
|
131
|
+
env = new Map();
|
|
132
|
+
examined = 0;
|
|
133
|
+
probes = 0;
|
|
134
|
+
steps = 0;
|
|
135
|
+
written = 0;
|
|
136
|
+
capped = false;
|
|
137
|
+
beyondHorizon = false;
|
|
138
|
+
page = null;
|
|
139
|
+
pageDepth = 0;
|
|
140
|
+
created = new Set();
|
|
141
|
+
tables = new Set();
|
|
142
|
+
writes = [];
|
|
143
|
+
site = "";
|
|
144
|
+
constructor(tx, schema, ctx, mode, ceilings = CEILINGS) {
|
|
145
|
+
this.tx = tx;
|
|
146
|
+
this.schema = schema;
|
|
147
|
+
this.ctx = ctx;
|
|
148
|
+
this.mode = mode;
|
|
149
|
+
this.ceilings = ceilings;
|
|
150
|
+
for (const [k, v] of Object.entries(ctx.args))
|
|
151
|
+
this.env.set(k, v);
|
|
152
|
+
}
|
|
153
|
+
charge(kind, n = 1) {
|
|
154
|
+
if (kind === "examined")
|
|
155
|
+
this.examined += n;
|
|
156
|
+
else
|
|
157
|
+
this.probes += n;
|
|
158
|
+
const over = kind === "examined" ? this.examined > this.ceilings.examined : this.probes > this.ceilings.ruleProbes;
|
|
159
|
+
if (!over)
|
|
160
|
+
return true;
|
|
161
|
+
if (this.mode === "mutation")
|
|
162
|
+
throw new Refused({ code: "E_BOUND", family: "bound", message: `${kind === "examined" ? "examined rows" : "rule probes"} reached the ceiling at ${this.site}`, site: this.site });
|
|
163
|
+
this.capped = true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
step(n = 1) {
|
|
167
|
+
this.steps += n;
|
|
168
|
+
if (this.steps > this.ceilings.steps)
|
|
169
|
+
throw new Refused({ code: "E_BOUND", family: "bound", message: `steps reached ${this.steps} against the ceiling ${this.ceilings.steps}`, site: this.site });
|
|
170
|
+
}
|
|
171
|
+
async run(program) {
|
|
172
|
+
// An omitted optional argument (or cursor) reads as null, as on the server.
|
|
173
|
+
for (const [name, kind] of Object.entries(program.args)) {
|
|
174
|
+
if (!this.env.has(name) && (kind === "Cursor" || (typeof kind === "object" && kind !== null && "Optional" in kind)))
|
|
175
|
+
this.env.set(name, null);
|
|
176
|
+
}
|
|
177
|
+
const flow = await this.statements(program.body, program.name);
|
|
178
|
+
const data = flow instanceof Flow && flow.kind === "return" ? flow.value : null;
|
|
179
|
+
return { data, complete: !this.capped && !this.beyondHorizon, next: this.capped ? null : this.nextCursor(), tables: this.tables };
|
|
180
|
+
}
|
|
181
|
+
nextCursor() {
|
|
182
|
+
if (!this.page || !this.page.next)
|
|
183
|
+
return null;
|
|
184
|
+
const table = this.schema.tables[this.page.table];
|
|
185
|
+
const columns = table.indexes[this.page.index].components.map((c) => ("Column" in c ? c.Column : "")).slice(this.page.prefixLen);
|
|
186
|
+
const suffix = columns.map((name) => ({ name, kind: (name === "id" ? "Id" : table.columns[name]) }));
|
|
187
|
+
return encodeCursor({ table: this.page.table, index: this.page.index, order: this.page.order, suffix, values: this.page.next });
|
|
188
|
+
}
|
|
189
|
+
async statements(stmts, prefix) {
|
|
190
|
+
for (let i = 0; i < stmts.length; i++) {
|
|
191
|
+
this.site = `${prefix}:${i + 1}`;
|
|
192
|
+
this.step();
|
|
193
|
+
const raw = stmts[i];
|
|
194
|
+
if (raw === "Break")
|
|
195
|
+
return new Flow("break");
|
|
196
|
+
if (raw === "Continue")
|
|
197
|
+
return new Flow("continue");
|
|
198
|
+
const [kind, body] = Object.entries(raw)[0];
|
|
199
|
+
switch (kind) {
|
|
200
|
+
case "Const":
|
|
201
|
+
case "Let":
|
|
202
|
+
case "Assign":
|
|
203
|
+
this.env.set(body.name, await this.expr(body.value));
|
|
204
|
+
break;
|
|
205
|
+
case "Push": {
|
|
206
|
+
const value = await this.expr(body.value);
|
|
207
|
+
const target = this.env.get(body.target);
|
|
208
|
+
if (!Array.isArray(target))
|
|
209
|
+
throw new Refused({ code: "E_TYPE", family: "input", message: `push into ${body.target}` });
|
|
210
|
+
target.push(value);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "If": {
|
|
214
|
+
const c = await this.expr(body.condition);
|
|
215
|
+
const flow = await this.statements((c ? body.then_body : body.else_body), `${this.site}:${c ? "then" : "else"}`);
|
|
216
|
+
if (flow)
|
|
217
|
+
return flow;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "For": {
|
|
221
|
+
const items = await this.domain(body.domain);
|
|
222
|
+
const isPage = typeof body.domain === "object" && body.domain !== null && ("Page" in body.domain || "Recurse" in body.domain);
|
|
223
|
+
if (isPage)
|
|
224
|
+
this.pageDepth++;
|
|
225
|
+
let result;
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
this.env.set(body.binding, item);
|
|
228
|
+
const flow = await this.statements(body.body, this.site);
|
|
229
|
+
if (flow?.kind === "break")
|
|
230
|
+
break;
|
|
231
|
+
if (flow?.kind === "return") {
|
|
232
|
+
result = flow;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (isPage)
|
|
237
|
+
this.pageDepth--;
|
|
238
|
+
if (result)
|
|
239
|
+
return result;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case "Require": {
|
|
243
|
+
if (!(await this.expr(body.condition)))
|
|
244
|
+
throw new Refused({ code: body.code, family: "rule", message: `the program refused with ${body.code}`, rule: body.code, site: body.site ?? this.site });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "Check": {
|
|
248
|
+
if (!(await this.expr(body.condition)))
|
|
249
|
+
throw new Refused({ code: "E_CONFLICT", family: "constraint", message: "the row no longer matches the update condition", retryable: true });
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "InsertIfAbsent": {
|
|
253
|
+
const row = (await this.expr(body.row));
|
|
254
|
+
const inserted = await this.insert(body.table, row, true);
|
|
255
|
+
this.env.set(body.result, inserted ? row : null);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "Insert":
|
|
259
|
+
await this.insert(body.table, (await this.expr(body.row)), false);
|
|
260
|
+
break;
|
|
261
|
+
case "Update":
|
|
262
|
+
await this.update(body.table, (await this.expr(body.key)), (await this.expr(body.patch)));
|
|
263
|
+
break;
|
|
264
|
+
case "Upsert":
|
|
265
|
+
await this.upsert(body.table, (await this.expr(body.key)), (await this.expr(body.row)));
|
|
266
|
+
break;
|
|
267
|
+
case "Delete":
|
|
268
|
+
await this.delete(body.table, (await this.expr(body.key)));
|
|
269
|
+
break;
|
|
270
|
+
case "Emit": throw new Refused({ code: "E_PREDICT", family: "predict", message: "effects run on the server; this write is not predicted", retryable: true });
|
|
271
|
+
case "Return": return new Flow("return", await this.expr(body));
|
|
272
|
+
default: throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown statement ${kind}` });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
async domain(domain) {
|
|
278
|
+
const [kind, body] = Object.entries(domain)[0];
|
|
279
|
+
switch (kind) {
|
|
280
|
+
case "Page": return this.scan(body);
|
|
281
|
+
case "LiteralRange": {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (let i = body.start; i < body.end_exclusive; i++)
|
|
284
|
+
out.push(i);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
case "ArgList": {
|
|
288
|
+
const v = this.env.get(body.name);
|
|
289
|
+
if (!Array.isArray(v))
|
|
290
|
+
return [];
|
|
291
|
+
if (v.length > body.max)
|
|
292
|
+
throw new Refused({ code: "E_FANOUT", family: "bound", message: "more items than the bound" });
|
|
293
|
+
return v;
|
|
294
|
+
}
|
|
295
|
+
case "DerivedArray": {
|
|
296
|
+
const v = await this.expr(body.value);
|
|
297
|
+
if (!Array.isArray(v))
|
|
298
|
+
return [];
|
|
299
|
+
if (v.length > body.max)
|
|
300
|
+
throw new Refused({ code: "E_FANOUT", family: "bound", message: "more items than the bound" });
|
|
301
|
+
return v;
|
|
302
|
+
}
|
|
303
|
+
case "Recurse": return this.recurse(body);
|
|
304
|
+
default: throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown loop domain ${kind}` });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async recurse(r) {
|
|
308
|
+
const seed = await this.expr(r.seed);
|
|
309
|
+
const queue = [[seed, 0]];
|
|
310
|
+
const visited = new Set();
|
|
311
|
+
const rows = [];
|
|
312
|
+
while (queue.length) {
|
|
313
|
+
const [key, depth] = queue.shift();
|
|
314
|
+
if (depth > r.depth || visited.has(canonicalJson(key)))
|
|
315
|
+
continue;
|
|
316
|
+
visited.add(canonicalJson(key));
|
|
317
|
+
const row = await this.get({ site: r.site, table: r.table, index: r.index, key: [{ Literal: tag(key) }] });
|
|
318
|
+
if (row === null)
|
|
319
|
+
continue;
|
|
320
|
+
rows.push(row);
|
|
321
|
+
const next = row[r.next_field];
|
|
322
|
+
if (next === null || next === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
if (Array.isArray(next)) {
|
|
325
|
+
if (next.length > r.fanout)
|
|
326
|
+
throw new Refused({ code: "E_FANOUT", family: "bound", message: "walk fan-out exceeds the bound" });
|
|
327
|
+
for (const n of next)
|
|
328
|
+
queue.push([n, depth + 1]);
|
|
329
|
+
}
|
|
330
|
+
else
|
|
331
|
+
queue.push([next, depth + 1]);
|
|
332
|
+
}
|
|
333
|
+
return rows;
|
|
334
|
+
}
|
|
335
|
+
async expr(e) {
|
|
336
|
+
this.step();
|
|
337
|
+
if (typeof e === "string") {
|
|
338
|
+
// Unit variants appear as bare strings.
|
|
339
|
+
throw new Refused({ code: "E_SCHEMA", family: "schema", message: `bare expression ${e}` });
|
|
340
|
+
}
|
|
341
|
+
const [kind, body] = Object.entries(e)[0];
|
|
342
|
+
switch (kind) {
|
|
343
|
+
case "Literal": return literal(body);
|
|
344
|
+
case "Variable": {
|
|
345
|
+
if (!this.env.has(body))
|
|
346
|
+
throw new Refused({ code: "E_INPUT", family: "input", message: `unknown variable ${body}` });
|
|
347
|
+
return this.env.get(body);
|
|
348
|
+
}
|
|
349
|
+
case "Input": switch (body) {
|
|
350
|
+
case "Viewer": return this.ctx.viewer;
|
|
351
|
+
case "Now": return this.ctx.now;
|
|
352
|
+
case "NewId": return this.ctx.newIds.shift() ?? this.ctx.mint();
|
|
353
|
+
default: return this.ctx.now;
|
|
354
|
+
}
|
|
355
|
+
case "Field": {
|
|
356
|
+
const b = body;
|
|
357
|
+
const base = await this.expr(b.base);
|
|
358
|
+
if (base === null || base === undefined)
|
|
359
|
+
return null;
|
|
360
|
+
if (typeof base !== "object")
|
|
361
|
+
throw new Refused({ code: "E_FIELD", family: "input", message: `no field ${b.name}` });
|
|
362
|
+
return base[b.name] ?? null;
|
|
363
|
+
}
|
|
364
|
+
case "Array": {
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const item of body)
|
|
367
|
+
out.push(await this.expr(item));
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
case "Object": {
|
|
371
|
+
const out = {};
|
|
372
|
+
for (const [k, v] of Object.entries(body))
|
|
373
|
+
out[k] = await this.expr(v);
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
case "Binary": {
|
|
377
|
+
const b = body;
|
|
378
|
+
const left = await this.expr(b.left);
|
|
379
|
+
if (b.op === "And" && !left)
|
|
380
|
+
return false;
|
|
381
|
+
if (b.op === "Or" && left)
|
|
382
|
+
return true;
|
|
383
|
+
const right = await this.expr(b.right);
|
|
384
|
+
switch (b.op) {
|
|
385
|
+
case "Eq": return equal(left, right);
|
|
386
|
+
case "Ne": return !equal(left, right);
|
|
387
|
+
case "Lt": return totalCompare(left, right) < 0;
|
|
388
|
+
case "Lte": return totalCompare(left, right) <= 0;
|
|
389
|
+
case "Gt": return totalCompare(left, right) > 0;
|
|
390
|
+
case "Gte": return totalCompare(left, right) >= 0;
|
|
391
|
+
case "Add": return num(left) + num(right);
|
|
392
|
+
case "Sub": return num(left) - num(right);
|
|
393
|
+
case "Mul": return num(left) * num(right);
|
|
394
|
+
case "And": return Boolean(left) && Boolean(right);
|
|
395
|
+
case "Or": return Boolean(left) || Boolean(right);
|
|
396
|
+
default: return equal(left, right);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
case "Unary": {
|
|
400
|
+
const b = body;
|
|
401
|
+
const v = await this.expr(b.value);
|
|
402
|
+
return b.op === "Not" ? !v : -num(v);
|
|
403
|
+
}
|
|
404
|
+
case "Builtin": return this.builtin(body);
|
|
405
|
+
case "Get": return this.get(body);
|
|
406
|
+
case "Exists": return (await this.get(body)) !== null;
|
|
407
|
+
case "Scan": return this.scan(body);
|
|
408
|
+
case "Merge": return this.merge(body);
|
|
409
|
+
default: throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown expression ${kind}` });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
async builtin(b) {
|
|
413
|
+
const args = [];
|
|
414
|
+
for (const a of b.args)
|
|
415
|
+
args.push(await this.expr(a));
|
|
416
|
+
const name = typeof b.builtin === "string" ? b.builtin : Object.keys(b.builtin)[0];
|
|
417
|
+
switch (name) {
|
|
418
|
+
case "Len":
|
|
419
|
+
case "Count":
|
|
420
|
+
case "CountCapped": {
|
|
421
|
+
const v = args[0];
|
|
422
|
+
return typeof v === "string" ? [...v].length : Array.isArray(v) ? v.length : v && typeof v === "object" ? Object.keys(v).length : 0;
|
|
423
|
+
}
|
|
424
|
+
case "Sum": return args[0].reduce((s, x) => s + x, 0);
|
|
425
|
+
case "Slice": {
|
|
426
|
+
const [v, s, e] = args;
|
|
427
|
+
const start = Math.max(0, s), end = Math.max(0, e);
|
|
428
|
+
return typeof v === "string" ? [...v].slice(start, end).join("") : v.slice(start, end);
|
|
429
|
+
}
|
|
430
|
+
case "Concat": {
|
|
431
|
+
const [l, r] = args;
|
|
432
|
+
return Array.isArray(l) && Array.isArray(r) ? [...l, ...r] : `${String(l ?? "")}${String(r ?? "")}`;
|
|
433
|
+
}
|
|
434
|
+
case "Min": return totalCompare(args[0], args[1]) <= 0 ? args[0] : args[1];
|
|
435
|
+
case "Max": return totalCompare(args[0], args[1]) >= 0 ? args[0] : args[1];
|
|
436
|
+
case "Divmod": {
|
|
437
|
+
const [n, d] = args;
|
|
438
|
+
if (d === 0)
|
|
439
|
+
throw new Refused({ code: "E_DIV_ZERO", family: "input", message: "division by zero" });
|
|
440
|
+
const q = Math.floor(n / d);
|
|
441
|
+
return { quotient: q, remainder: n - q * d, sink: "lexicographic-id" };
|
|
442
|
+
}
|
|
443
|
+
case "Sort": {
|
|
444
|
+
const keys = b.builtin.Sort.keys;
|
|
445
|
+
return [...args[0]].sort((x, y) => { for (const k of keys) {
|
|
446
|
+
const c = totalCompare(x[k.field], y[k.field]);
|
|
447
|
+
if (c !== 0)
|
|
448
|
+
return k.order === "Desc" ? -c : c;
|
|
449
|
+
} return 0; });
|
|
450
|
+
}
|
|
451
|
+
case "MentionTokens": {
|
|
452
|
+
const seen = new Set();
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const w of String(args[0]).split(/[\s,;()[\]{}<>"']+/)) {
|
|
455
|
+
if (!w.startsWith("@"))
|
|
456
|
+
continue;
|
|
457
|
+
const t = w.slice(1).replace(/^[^\p{L}\p{N}_]+|[^\p{L}\p{N}_]+$/gu, "");
|
|
458
|
+
if (t && !seen.has(t)) {
|
|
459
|
+
seen.add(t);
|
|
460
|
+
out.push(t);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
case "DeterministicShare": {
|
|
466
|
+
const [amount, list, who] = args;
|
|
467
|
+
const sorted = [...list].sort(totalCompare);
|
|
468
|
+
const at = sorted.findIndex((x) => equal(x, who));
|
|
469
|
+
if (at < 0)
|
|
470
|
+
throw new Refused({ code: "E_INPUT", family: "input", message: "recipient missing from shares" });
|
|
471
|
+
const n = sorted.length;
|
|
472
|
+
const base = Math.floor(amount / n);
|
|
473
|
+
return base + (at < amount - base * n ? 1 : 0);
|
|
474
|
+
}
|
|
475
|
+
default: throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown builtin ${name}` });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// ----- reads -----
|
|
479
|
+
async get(read) {
|
|
480
|
+
const key = [];
|
|
481
|
+
for (const k of read.key)
|
|
482
|
+
key.push(await this.expr(k));
|
|
483
|
+
this.site = read.site;
|
|
484
|
+
this.tables.add(read.table);
|
|
485
|
+
const table = this.schema.tables[read.table];
|
|
486
|
+
if (!table)
|
|
487
|
+
throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown table ${read.table}` });
|
|
488
|
+
if (!table.sync)
|
|
489
|
+
this.beyondHorizon = true;
|
|
490
|
+
// Absent, hidden or visible: one examined row; the probes that decided
|
|
491
|
+
// a hidden row are rolled back, as on the server.
|
|
492
|
+
const mark = { probes: this.probes, capped: this.capped };
|
|
493
|
+
const row = await this.tx.lookup(read.table, read.index, key);
|
|
494
|
+
if (!this.charge("examined"))
|
|
495
|
+
return null;
|
|
496
|
+
if (!row)
|
|
497
|
+
return null;
|
|
498
|
+
if (await this.readable(read.table, row))
|
|
499
|
+
return row;
|
|
500
|
+
this.probes = mark.probes;
|
|
501
|
+
this.capped = mark.capped;
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
async scan(scan) {
|
|
505
|
+
const table = scan.table, index = scan.index;
|
|
506
|
+
const prefix = [];
|
|
507
|
+
for (const p of scan.prefix)
|
|
508
|
+
prefix.push(p.Scalar === undefined ? null : await this.expr(p.Scalar));
|
|
509
|
+
const definition = this.schema.tables[table];
|
|
510
|
+
if (!definition)
|
|
511
|
+
throw new Refused({ code: "E_SCHEMA", family: "schema", message: `unknown table ${table}` });
|
|
512
|
+
this.tables.add(table);
|
|
513
|
+
this.site = scan.site;
|
|
514
|
+
if (!definition.sync)
|
|
515
|
+
this.beyondHorizon = true;
|
|
516
|
+
const take = typeof scan.take === "object" && scan.take !== null && "Literal" in scan.take ? scan.take.Literal : num(this.env.get(scan.take.Arg.name));
|
|
517
|
+
const order = scan.order;
|
|
518
|
+
const bounds = {};
|
|
519
|
+
if (scan.after) {
|
|
520
|
+
const after = await this.expr(scan.after);
|
|
521
|
+
if (after !== null && after !== undefined) {
|
|
522
|
+
if (scan.opaque_cursor) {
|
|
523
|
+
const position = decodeCursor(String(after));
|
|
524
|
+
if (!position || position.table !== table || position.index !== index || position.order !== order)
|
|
525
|
+
throw new Refused({ code: "E_INPUT", family: "input", message: "cursor: the token names another scope" });
|
|
526
|
+
bounds.after = position.values;
|
|
527
|
+
}
|
|
528
|
+
else
|
|
529
|
+
bounds.after = Array.isArray(after) ? after : [after];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (scan.before) {
|
|
533
|
+
const before = await this.expr(scan.before);
|
|
534
|
+
if (before !== null && before !== undefined)
|
|
535
|
+
bounds.before = Array.isArray(before) ? before : [before];
|
|
536
|
+
}
|
|
537
|
+
if (scan.range) {
|
|
538
|
+
const r = scan.range;
|
|
539
|
+
for (const k of ["gt", "gte", "lt", "lte"])
|
|
540
|
+
if (r[k])
|
|
541
|
+
bounds[k] = await this.expr(r[k]);
|
|
542
|
+
}
|
|
543
|
+
const dir = order === "Asc" ? "asc" : "desc";
|
|
544
|
+
const visible = [];
|
|
545
|
+
let lastKey = null;
|
|
546
|
+
let hasMore = false;
|
|
547
|
+
let cursor = { ...bounds };
|
|
548
|
+
const batch = take + 1;
|
|
549
|
+
const { uniform, decidedTrue } = this.visibility(table, index, scan.prefix);
|
|
550
|
+
let verdict = decidedTrue ? true : null;
|
|
551
|
+
const mark = { examined: this.examined, probes: this.probes, capped: this.capped };
|
|
552
|
+
outer: for (;;) {
|
|
553
|
+
const fetched = await this.tx.scan(table, index, prefix, cursor, dir, batch);
|
|
554
|
+
if (fetched.length === 0)
|
|
555
|
+
break;
|
|
556
|
+
for (const { row, key } of fetched) {
|
|
557
|
+
if (!this.charge("examined"))
|
|
558
|
+
break outer;
|
|
559
|
+
let allowed;
|
|
560
|
+
if (verdict !== null)
|
|
561
|
+
allowed = verdict;
|
|
562
|
+
else {
|
|
563
|
+
allowed = await this.readable(table, row);
|
|
564
|
+
if (uniform)
|
|
565
|
+
verdict = allowed;
|
|
566
|
+
}
|
|
567
|
+
if (this.capped)
|
|
568
|
+
break outer;
|
|
569
|
+
if (allowed) {
|
|
570
|
+
if (visible.length === take) {
|
|
571
|
+
hasMore = true;
|
|
572
|
+
break outer;
|
|
573
|
+
}
|
|
574
|
+
visible.push(row);
|
|
575
|
+
lastKey = key;
|
|
576
|
+
}
|
|
577
|
+
else if (uniform) {
|
|
578
|
+
// One hidden row hides the prefix: stop without reading it, at
|
|
579
|
+
// the cost of an empty range.
|
|
580
|
+
this.examined = mark.examined;
|
|
581
|
+
this.probes = mark.probes;
|
|
582
|
+
this.capped = mark.capped;
|
|
583
|
+
break outer;
|
|
584
|
+
}
|
|
585
|
+
cursor = { ...cursor, after: suffixOf(this.schema, table, index, row, prefix.length) };
|
|
586
|
+
}
|
|
587
|
+
if (fetched.length < batch)
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
// Inside the horizon, a short page is the whole group; at the horizon
|
|
591
|
+
// it may continue on the server.
|
|
592
|
+
const horizon = definition.sync?.horizon;
|
|
593
|
+
if (horizon && !hasMore && !this.capped && horizon.by === index) {
|
|
594
|
+
const held = await this.tx.count(table, index, prefix, horizon.last);
|
|
595
|
+
if (held >= horizon.last)
|
|
596
|
+
this.beyondHorizon = true;
|
|
597
|
+
}
|
|
598
|
+
if (this.pageDepth === 0 && scan.opaque_cursor) {
|
|
599
|
+
const columns = definition.indexes[index].components.length;
|
|
600
|
+
this.page = { table, index, order, prefixLen: prefix.length, next: hasMore && lastKey && visible.length ? suffixOf(this.schema, table, index, visible[visible.length - 1], prefix.length).slice(0, columns - prefix.length) : null };
|
|
601
|
+
}
|
|
602
|
+
return visible;
|
|
603
|
+
}
|
|
604
|
+
async merge(merge) {
|
|
605
|
+
const lanes = merge.lanes;
|
|
606
|
+
const first = lanes[0]?.scan;
|
|
607
|
+
if (!first)
|
|
608
|
+
return [];
|
|
609
|
+
const table = first.table, index = first.index, order = first.order;
|
|
610
|
+
const take = first.take.Literal;
|
|
611
|
+
const columns = this.schema.tables[table].indexes[index].components.map((c) => ("Column" in c ? c.Column : ""));
|
|
612
|
+
const prefixLen = first.prefix.length;
|
|
613
|
+
const suffixColumns = columns.slice(prefixLen);
|
|
614
|
+
const candidates = [];
|
|
615
|
+
const seen = new Set();
|
|
616
|
+
const depth = this.pageDepth;
|
|
617
|
+
for (const lane of lanes) {
|
|
618
|
+
const sources = lane.source ? await (async () => { this.pageDepth++; const r = await this.scan(lane.source); this.pageDepth--; return r; })() : [null];
|
|
619
|
+
for (const source of sources) {
|
|
620
|
+
if (lane.source)
|
|
621
|
+
this.env.set(lane.binding, source);
|
|
622
|
+
await this.statements(lane.setup ?? [], merge.site);
|
|
623
|
+
this.pageDepth++;
|
|
624
|
+
const rows = await this.scan(lane.scan);
|
|
625
|
+
this.pageDepth = depth;
|
|
626
|
+
for (const row of rows) {
|
|
627
|
+
if (seen.has(row.id))
|
|
628
|
+
continue;
|
|
629
|
+
seen.add(row.id);
|
|
630
|
+
candidates.push({ key: suffixColumns.map((c) => row[c] ?? null), row });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
candidates.sort((a, b) => { const c = totalCompare(a.key, b.key); return order === "Asc" ? c : -c; });
|
|
635
|
+
const hasMore = candidates.length > take;
|
|
636
|
+
const page = candidates.slice(0, take);
|
|
637
|
+
if (this.pageDepth === 0 && first.opaque_cursor)
|
|
638
|
+
this.page = { table, index, order, prefixLen, next: hasMore && page.length ? page[page.length - 1].key : null };
|
|
639
|
+
return page.map((c) => c.row);
|
|
640
|
+
}
|
|
641
|
+
/** The server's `scan_visibility`, on the device: a read rule whose every
|
|
642
|
+
* field the prefix pins is decided once for the whole range (`uniform`);
|
|
643
|
+
* one the prefix proves is `decidedTrue` before any row is read. A
|
|
644
|
+
* uniform-false range halts after one row at the cost of an empty one,
|
|
645
|
+
* so the rows the viewer may no longer read (delivered history) are never
|
|
646
|
+
* counted. */
|
|
647
|
+
visibility(table, index, prefix) {
|
|
648
|
+
const definition = this.schema.tables[table];
|
|
649
|
+
const rule = definition.rules.read;
|
|
650
|
+
const pinned = new Map();
|
|
651
|
+
definition.indexes[index].components.forEach((component, n) => {
|
|
652
|
+
if ("Column" in component && n < prefix.length && prefix[n].Scalar !== undefined)
|
|
653
|
+
pinned.set(component.Column, prefix[n].Scalar);
|
|
654
|
+
});
|
|
655
|
+
const decidedTrue = this.ruleTrue(rule, table, pinned);
|
|
656
|
+
const used = new Set();
|
|
657
|
+
ruleFields(rule, used);
|
|
658
|
+
return { uniform: decidedTrue || [...used].every((field) => pinned.has(field)), decidedTrue };
|
|
659
|
+
}
|
|
660
|
+
ruleTrue(rule, table, pinned) {
|
|
661
|
+
if (rule === "Allow")
|
|
662
|
+
return true;
|
|
663
|
+
if (typeof rule !== "object" || rule === null)
|
|
664
|
+
return false;
|
|
665
|
+
const [kind, body] = Object.entries(rule)[0];
|
|
666
|
+
switch (kind) {
|
|
667
|
+
case "Public": return true;
|
|
668
|
+
case "Eq": {
|
|
669
|
+
const [a, b] = body;
|
|
670
|
+
return [[a, b], [b, a]].some(([field, value]) => typeof field === "object" && field !== null && "Field" in field && pinnedTerm(value, pinned.get(field.Field)));
|
|
671
|
+
}
|
|
672
|
+
case "Or": return body.some((r) => this.ruleTrue(r, table, pinned));
|
|
673
|
+
case "And": return body.length > 0 && body.every((r) => this.ruleTrue(r, table, pinned));
|
|
674
|
+
case "Exists": {
|
|
675
|
+
// A self-probe on a total unique key whose every component is the
|
|
676
|
+
// row's own column or pinned to the probing value holds for every
|
|
677
|
+
// row the prefix selects.
|
|
678
|
+
const b = body;
|
|
679
|
+
if (b.table !== table)
|
|
680
|
+
return false;
|
|
681
|
+
const definition = this.schema.tables[table];
|
|
682
|
+
const index = definition.indexes[b.index];
|
|
683
|
+
if (!index || !index.unique || b.key.length === 0 || b.key.length !== index.components.length)
|
|
684
|
+
return false;
|
|
685
|
+
return index.components.every((component, n) => {
|
|
686
|
+
if (!("Column" in component))
|
|
687
|
+
return false;
|
|
688
|
+
const type = definition.columns[component.Column];
|
|
689
|
+
if (typeof type === "object" && type !== null && "Optional" in type)
|
|
690
|
+
return false;
|
|
691
|
+
const term = b.key[n];
|
|
692
|
+
if (typeof term === "object" && term !== null && "Field" in term)
|
|
693
|
+
return term.Field === component.Column;
|
|
694
|
+
return pinnedTerm(term, pinned.get(component.Column));
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
default: return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** The read rule for one row, with probes over the replica. */
|
|
701
|
+
async readable(table, row) {
|
|
702
|
+
const rule = this.schema.tables[table].rules.read;
|
|
703
|
+
return this.rule(rule, undefined, row, false);
|
|
704
|
+
}
|
|
705
|
+
async rule(rule, old, next, insert) {
|
|
706
|
+
if (rule === "Allow")
|
|
707
|
+
return true;
|
|
708
|
+
if (rule === "Deny")
|
|
709
|
+
return false;
|
|
710
|
+
const [kind, body] = Object.entries(rule)[0];
|
|
711
|
+
const term = (t) => {
|
|
712
|
+
if (t === "Viewer")
|
|
713
|
+
return this.ctx.viewer;
|
|
714
|
+
const [k, v] = Object.entries(t)[0];
|
|
715
|
+
if (k === "Field" || k === "NextField")
|
|
716
|
+
return next[v] ?? null;
|
|
717
|
+
if (k === "OldField")
|
|
718
|
+
return old?.[v] ?? null;
|
|
719
|
+
return literal(v);
|
|
720
|
+
};
|
|
721
|
+
switch (kind) {
|
|
722
|
+
case "Public": return true;
|
|
723
|
+
case "Eq": return equal(term(body[0]), term(body[1]));
|
|
724
|
+
case "Ne": return !equal(term(body[0]), term(body[1]));
|
|
725
|
+
case "And":
|
|
726
|
+
for (const r of body)
|
|
727
|
+
if (!(await this.rule(r, old, next, insert)))
|
|
728
|
+
return false;
|
|
729
|
+
return true;
|
|
730
|
+
case "Or":
|
|
731
|
+
for (const r of body)
|
|
732
|
+
if (await this.rule(r, old, next, insert))
|
|
733
|
+
return true;
|
|
734
|
+
return false;
|
|
735
|
+
case "Not": return !(await this.rule(body, old, next, insert));
|
|
736
|
+
case "Exists": {
|
|
737
|
+
const b = body;
|
|
738
|
+
if (!this.charge("probes"))
|
|
739
|
+
return false;
|
|
740
|
+
this.tables.add(b.table);
|
|
741
|
+
return (await this.tx.lookup(b.table, b.index, b.key.map(term))) !== undefined;
|
|
742
|
+
}
|
|
743
|
+
case "Created": {
|
|
744
|
+
const b = body;
|
|
745
|
+
return this.created.has(`${b.table}\u0000${b.index}\u0000${canonicalJson(b.key.map(term))}`);
|
|
746
|
+
}
|
|
747
|
+
default: return false;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// ----- writes (predictions) -----
|
|
751
|
+
async permitted(table, effect, old, next) {
|
|
752
|
+
const rules = this.schema.tables[table].rules;
|
|
753
|
+
if (!(await this.rule(rules[effect], old, next, effect === "insert")))
|
|
754
|
+
throw new Refused({ code: "E_RULE", family: "auth", message: `${table}'s ${effect} rule does not admit this viewer` });
|
|
755
|
+
}
|
|
756
|
+
async constraints(table, old, row, effect) {
|
|
757
|
+
const definition = this.schema.tables[table];
|
|
758
|
+
for (const [name, index] of Object.entries(definition.indexes)) {
|
|
759
|
+
if (!index.unique)
|
|
760
|
+
continue;
|
|
761
|
+
const columns = index.components.map((c) => ("Column" in c ? c.Column : ""));
|
|
762
|
+
const key = columns.map((c) => row[c] ?? null);
|
|
763
|
+
if (key.some((v) => v === null))
|
|
764
|
+
continue;
|
|
765
|
+
const hit = await this.tx.lookup(table, name, key);
|
|
766
|
+
if (hit && hit.id !== row.id)
|
|
767
|
+
throw new Refused({ code: "E_CONSTRAINT", family: "constraint", message: `${table} already has a row where ${columns.join(", ")} = ${key.map(String).join(", ")}` });
|
|
768
|
+
}
|
|
769
|
+
for (const constraint of definition.constraints ?? []) {
|
|
770
|
+
if (typeof constraint !== "object" || constraint === null)
|
|
771
|
+
continue;
|
|
772
|
+
if ("Cap" in constraint) {
|
|
773
|
+
const { by, max } = constraint.Cap;
|
|
774
|
+
const group = by.map((c) => row[c] ?? null);
|
|
775
|
+
const moved = !old || by.some((c) => !equal(old[c] ?? null, row[c] ?? null));
|
|
776
|
+
if (!moved)
|
|
777
|
+
continue;
|
|
778
|
+
const index = Object.entries(definition.indexes).find(([, i]) => by.every((c, n) => i.components[n]?.Column === c))?.[0];
|
|
779
|
+
if (!index)
|
|
780
|
+
continue;
|
|
781
|
+
if ((await this.tx.count(table, index, group, max + 1)) >= max)
|
|
782
|
+
throw new Refused({ code: "E_CONSTRAINT", family: "constraint", message: `${table} holds at most ${max} rows where ${by.join(", ")} = ${group.map(String).join(", ")}` });
|
|
783
|
+
}
|
|
784
|
+
if ("Immutable" in constraint && old && effect === "update") {
|
|
785
|
+
for (const field of constraint.Immutable.fields)
|
|
786
|
+
if (!equal(old[field] ?? null, row[field] ?? null))
|
|
787
|
+
throw new Refused({ code: "E_CONSTRAINT", family: "constraint", message: `${table}.${field} is immutable` });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async insert(table, row, ifAbsent) {
|
|
792
|
+
this.tables.add(table);
|
|
793
|
+
await this.permitted(table, "insert", undefined, row);
|
|
794
|
+
if (ifAbsent) {
|
|
795
|
+
for (const [name, index] of Object.entries(this.schema.tables[table].indexes)) {
|
|
796
|
+
if (!index.unique)
|
|
797
|
+
continue;
|
|
798
|
+
const key = index.components.map((c) => ("Column" in c ? row[c.Column] ?? null : null));
|
|
799
|
+
if (await this.tx.lookup(table, name, key))
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (await this.tx.get(table, row.id))
|
|
804
|
+
throw new Refused({ code: "E_CONSTRAINT", family: "constraint", message: `${table} already has a row with id ${row.id}` });
|
|
805
|
+
await this.constraints(table, undefined, row, "insert");
|
|
806
|
+
await this.put(table, { ...row, pending: true });
|
|
807
|
+
for (const [name, index] of Object.entries(this.schema.tables[table].indexes))
|
|
808
|
+
this.created.add(`${table}\u0000${name}\u0000${canonicalJson(index.components.map((c) => ("Column" in c ? row[c.Column] ?? null : null)))}`);
|
|
809
|
+
return true;
|
|
810
|
+
}
|
|
811
|
+
async update(table, id, patch) {
|
|
812
|
+
this.tables.add(table);
|
|
813
|
+
const old = await this.tx.get(table, id);
|
|
814
|
+
if (!old)
|
|
815
|
+
throw new Refused({ code: "NOT_FOUND", family: "rule", message: "the row does not exist on this device", rule: "NOT_FOUND" });
|
|
816
|
+
const next = { ...old, ...patch, pending: true };
|
|
817
|
+
await this.permitted(table, "update", old, next);
|
|
818
|
+
await this.constraints(table, old, next, "update");
|
|
819
|
+
await this.put(table, next, old);
|
|
820
|
+
}
|
|
821
|
+
async upsert(table, id, row) {
|
|
822
|
+
const old = await this.tx.get(table, id);
|
|
823
|
+
if (old)
|
|
824
|
+
return this.update(table, id, row);
|
|
825
|
+
await this.insert(table, row, false);
|
|
826
|
+
}
|
|
827
|
+
async delete(table, id) {
|
|
828
|
+
this.tables.add(table);
|
|
829
|
+
const old = await this.tx.get(table, id);
|
|
830
|
+
if (!old)
|
|
831
|
+
throw new Refused({ code: "NOT_FOUND", family: "rule", message: "the row does not exist on this device", rule: "NOT_FOUND" });
|
|
832
|
+
await this.permitted(table, "delete", undefined, old);
|
|
833
|
+
this.written++;
|
|
834
|
+
if (this.written > this.ceilings.rowsWritten)
|
|
835
|
+
throw new Refused({ code: "E_BOUND", family: "bound", message: "rows written reached the ceiling", site: this.site });
|
|
836
|
+
await this.tx.delete(table, id);
|
|
837
|
+
this.writes.push({ table, row: old, old });
|
|
838
|
+
await this.maintain(table, old, undefined);
|
|
839
|
+
}
|
|
840
|
+
async put(table, row, old) {
|
|
841
|
+
this.written++;
|
|
842
|
+
if (this.written > this.ceilings.rowsWritten)
|
|
843
|
+
throw new Refused({ code: "E_BOUND", family: "bound", message: "rows written reached the ceiling", site: this.site });
|
|
844
|
+
await this.tx.put(table, row);
|
|
845
|
+
this.writes.push(old ? { table, row, old } : { table, row });
|
|
846
|
+
await this.maintain(table, old, row);
|
|
847
|
+
}
|
|
848
|
+
/** A device keeps its counts honest while a prediction stands. */
|
|
849
|
+
async maintain(source, old, next) {
|
|
850
|
+
for (const maintain of (this.schema.maintains ?? [])) {
|
|
851
|
+
if (maintain.source !== source)
|
|
852
|
+
continue;
|
|
853
|
+
const delta = (row, sign) => (row ? (maintain.kind === "Count" ? sign : sign * num(row[maintain.kind.Sum.of])) : 0);
|
|
854
|
+
const groups = new Map();
|
|
855
|
+
for (const [row, sign] of [[old, -1], [next, 1]]) {
|
|
856
|
+
if (!row)
|
|
857
|
+
continue;
|
|
858
|
+
const key = maintain.by.map((c) => row[c] ?? null);
|
|
859
|
+
const id = canonicalJson(key.map(tag));
|
|
860
|
+
const g = groups.get(id) ?? { key, change: 0 };
|
|
861
|
+
g.change += delta(row, sign);
|
|
862
|
+
groups.set(id, g);
|
|
863
|
+
}
|
|
864
|
+
for (const [id, g] of groups) {
|
|
865
|
+
if (g.change === 0)
|
|
866
|
+
continue;
|
|
867
|
+
const existing = await this.tx.get(maintain.target, id);
|
|
868
|
+
const value = num(existing?.value ?? 0) + g.change;
|
|
869
|
+
if (value === 0) {
|
|
870
|
+
if (existing)
|
|
871
|
+
await this.tx.delete(maintain.target, id);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
const row = { id, value, pending: true };
|
|
875
|
+
maintain.by.forEach((c, i) => { row[c] = g.key[i]; });
|
|
876
|
+
await this.tx.put(maintain.target, row);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
function num(v) {
|
|
882
|
+
if (typeof v !== "number")
|
|
883
|
+
throw new Refused({ code: "E_TYPE", family: "input", message: `expected a number, got ${typeof v}` });
|
|
884
|
+
return v;
|
|
885
|
+
}
|
|
886
|
+
/** A plain value as the server's tagged literal (for canonical ids). */
|
|
887
|
+
export function tag(v) {
|
|
888
|
+
if (v === null || v === undefined)
|
|
889
|
+
return "Null";
|
|
890
|
+
if (typeof v === "boolean")
|
|
891
|
+
return { Bool: v };
|
|
892
|
+
if (typeof v === "number")
|
|
893
|
+
return { Int: v };
|
|
894
|
+
if (typeof v === "string")
|
|
895
|
+
return { Id: v };
|
|
896
|
+
if (Array.isArray(v))
|
|
897
|
+
return { Array: v.map(tag) };
|
|
898
|
+
return { Row: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, tag(x)])) };
|
|
899
|
+
}
|
|
900
|
+
function suffixOf(schema, table, index, row, prefixLen) {
|
|
901
|
+
const columns = schema.tables[table].indexes[index].components.map((c) => ("Column" in c ? c.Column : ""));
|
|
902
|
+
const suffix = columns.slice(prefixLen).map((c) => row[c] ?? null);
|
|
903
|
+
if (columns[columns.length - 1] !== "id")
|
|
904
|
+
suffix.push(row.id);
|
|
905
|
+
return suffix;
|
|
906
|
+
}
|