@syncular/client 0.4.1 → 0.5.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/README.md +4 -4
- package/dist/apply.d.ts +5 -1
- package/dist/apply.js +6 -4
- package/dist/client.d.ts +49 -2
- package/dist/client.js +527 -259
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +81 -48
- package/dist/invalidation.js +130 -42
- package/dist/reactive-store.d.ts +74 -0
- package/dist/reactive-store.js +576 -0
- package/dist/schema.js +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.js +29 -0
- package/dist/window.d.ts +6 -1
- package/dist/window.js +0 -0
- package/dist/worker-entry.js +78 -52
- package/dist/worker-host.d.ts +8 -4
- package/dist/worker-host.js +26 -6
- package/dist/worker-protocol.d.ts +12 -14
- package/package.json +3 -3
- package/src/apply.ts +18 -5
- package/src/client.ts +685 -311
- package/src/index.ts +1 -0
- package/src/invalidation.ts +216 -62
- package/src/reactive-store.ts +695 -0
- package/src/schema.ts +3 -0
- package/src/state.ts +32 -0
- package/src/window.ts +0 -0
- package/src/worker-entry.ts +83 -54
- package/src/worker-host.ts +44 -8
- package/src/worker-protocol.ts +20 -13
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { windowBaseKey } from './window.js';
|
|
2
|
+
function errorOf(value) {
|
|
3
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
4
|
+
}
|
|
5
|
+
function readCollection(value) {
|
|
6
|
+
return typeof value === 'function' ? value() : value;
|
|
7
|
+
}
|
|
8
|
+
function unsupportedCanonicalValue(value) {
|
|
9
|
+
const description = Object.prototype.toString.call(value);
|
|
10
|
+
throw new TypeError(`unsupported reactive cache-key value ${description}; use null, string, finite number, bigint, boolean, bytes, arrays, or plain objects`);
|
|
11
|
+
}
|
|
12
|
+
function encodeCanonical(value, stack) {
|
|
13
|
+
if (value === null)
|
|
14
|
+
return 'n';
|
|
15
|
+
if (typeof value === 'string')
|
|
16
|
+
return `s${value.length}:${value}`;
|
|
17
|
+
if (typeof value === 'number') {
|
|
18
|
+
if (!Number.isFinite(value))
|
|
19
|
+
return unsupportedCanonicalValue(value);
|
|
20
|
+
if (Object.is(value, -0))
|
|
21
|
+
return 'd-0';
|
|
22
|
+
return `d${value}`;
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === 'bigint')
|
|
25
|
+
return `i${value}`;
|
|
26
|
+
if (typeof value === 'boolean')
|
|
27
|
+
return value ? 'b1' : 'b0';
|
|
28
|
+
if (value instanceof Uint8Array) {
|
|
29
|
+
let hex = '';
|
|
30
|
+
for (const byte of value)
|
|
31
|
+
hex += byte.toString(16).padStart(2, '0');
|
|
32
|
+
return `x${hex}`;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) {
|
|
35
|
+
if (stack.has(value))
|
|
36
|
+
return unsupportedCanonicalValue(value);
|
|
37
|
+
stack.add(value);
|
|
38
|
+
const encoded = `a${value.length}[${value
|
|
39
|
+
.map((member) => encodeCanonical(member, stack))
|
|
40
|
+
.join('')}]`;
|
|
41
|
+
stack.delete(value);
|
|
42
|
+
return encoded;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === 'object') {
|
|
45
|
+
const prototype = Object.getPrototypeOf(value);
|
|
46
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
47
|
+
return unsupportedCanonicalValue(value);
|
|
48
|
+
}
|
|
49
|
+
if (stack.has(value))
|
|
50
|
+
return unsupportedCanonicalValue(value);
|
|
51
|
+
stack.add(value);
|
|
52
|
+
const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
53
|
+
const encoded = `o${entries.length}{${entries
|
|
54
|
+
.map(([key, member]) => `${encodeCanonical(key, stack)}${encodeCanonical(member, stack)}`)
|
|
55
|
+
.join('')}}`;
|
|
56
|
+
stack.delete(value);
|
|
57
|
+
return encoded;
|
|
58
|
+
}
|
|
59
|
+
return unsupportedCanonicalValue(value);
|
|
60
|
+
}
|
|
61
|
+
/** Lossless deterministic identity for query params, bytes, and row keys. */
|
|
62
|
+
export function canonicalValue(value) {
|
|
63
|
+
return encodeCanonical(value, new Set());
|
|
64
|
+
}
|
|
65
|
+
function scheduleMicrotask(task) {
|
|
66
|
+
if (typeof queueMicrotask === 'function')
|
|
67
|
+
queueMicrotask(task);
|
|
68
|
+
else
|
|
69
|
+
void Promise.resolve().then(task);
|
|
70
|
+
}
|
|
71
|
+
function batchMatches(batch, spec) {
|
|
72
|
+
for (const change of batch.tables) {
|
|
73
|
+
const dependency = spec.dependencies.find((candidate) => candidate.table === change.table);
|
|
74
|
+
if (dependency === undefined)
|
|
75
|
+
continue;
|
|
76
|
+
if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
for (const key of dependency.scopeKeys) {
|
|
80
|
+
if (change.scopeKeys.has(key))
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const change of batch.windows) {
|
|
85
|
+
for (const coverage of spec.coverage ?? []) {
|
|
86
|
+
if (windowBaseKey(coverage.base) !== change.baseKey)
|
|
87
|
+
continue;
|
|
88
|
+
if (coverage.units.some((unit) => change.units.has(unit)))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
function valuesEqual(left, right) {
|
|
95
|
+
if (Object.is(left, right))
|
|
96
|
+
return true;
|
|
97
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
98
|
+
if (!(left instanceof Uint8Array) || !(right instanceof Uint8Array)) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
if (left.byteLength !== right.byteLength)
|
|
102
|
+
return false;
|
|
103
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
104
|
+
if (left[index] !== right[index])
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
110
|
+
if (!Array.isArray(left) || !Array.isArray(right))
|
|
111
|
+
return false;
|
|
112
|
+
return (left.length === right.length &&
|
|
113
|
+
left.every((member, index) => valuesEqual(member, right[index])));
|
|
114
|
+
}
|
|
115
|
+
if (left === null ||
|
|
116
|
+
right === null ||
|
|
117
|
+
typeof left !== 'object' ||
|
|
118
|
+
typeof right !== 'object') {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const leftRecord = left;
|
|
122
|
+
const rightRecord = right;
|
|
123
|
+
const keys = Object.keys(leftRecord);
|
|
124
|
+
if (keys.length !== Object.keys(rightRecord).length)
|
|
125
|
+
return false;
|
|
126
|
+
return keys.every((key) => Object.hasOwn(rightRecord, key) &&
|
|
127
|
+
valuesEqual(leftRecord[key], rightRecord[key]));
|
|
128
|
+
}
|
|
129
|
+
function reconcileRows(previous, fresh, rowKey) {
|
|
130
|
+
if (previous.length === 0)
|
|
131
|
+
return fresh;
|
|
132
|
+
if (rowKey === undefined) {
|
|
133
|
+
let changed = previous.length !== fresh.length;
|
|
134
|
+
const next = fresh.map((row, index) => {
|
|
135
|
+
const prior = previous[index];
|
|
136
|
+
if (prior !== undefined && valuesEqual(prior, row))
|
|
137
|
+
return prior;
|
|
138
|
+
changed = true;
|
|
139
|
+
return row;
|
|
140
|
+
});
|
|
141
|
+
return changed ? next : previous;
|
|
142
|
+
}
|
|
143
|
+
const priorByKey = new Map();
|
|
144
|
+
let duplicate = false;
|
|
145
|
+
for (const row of previous) {
|
|
146
|
+
const key = canonicalValue(rowKey(row));
|
|
147
|
+
if (priorByKey.has(key))
|
|
148
|
+
duplicate = true;
|
|
149
|
+
priorByKey.set(key, row);
|
|
150
|
+
}
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const next = fresh.map((row) => {
|
|
153
|
+
const key = canonicalValue(rowKey(row));
|
|
154
|
+
if (seen.has(key))
|
|
155
|
+
duplicate = true;
|
|
156
|
+
seen.add(key);
|
|
157
|
+
const prior = priorByKey.get(key);
|
|
158
|
+
return prior !== undefined && valuesEqual(prior, row) ? prior : row;
|
|
159
|
+
});
|
|
160
|
+
if (duplicate)
|
|
161
|
+
return reconcileRows(previous, fresh, undefined);
|
|
162
|
+
return next.length === previous.length &&
|
|
163
|
+
next.every((row, i) => row === previous[i])
|
|
164
|
+
? previous
|
|
165
|
+
: next;
|
|
166
|
+
}
|
|
167
|
+
class QueryEntry {
|
|
168
|
+
store;
|
|
169
|
+
spec;
|
|
170
|
+
#owner = Symbol('query-window-claim');
|
|
171
|
+
#listeners = new Set();
|
|
172
|
+
#state = {
|
|
173
|
+
rows: [],
|
|
174
|
+
phase: 'loading',
|
|
175
|
+
revision: undefined,
|
|
176
|
+
error: undefined,
|
|
177
|
+
isRefreshing: false,
|
|
178
|
+
};
|
|
179
|
+
#subscribers = 0;
|
|
180
|
+
#scheduled = false;
|
|
181
|
+
#running = false;
|
|
182
|
+
#requested = false;
|
|
183
|
+
#desiredRevision = 0n;
|
|
184
|
+
#claimReady = Promise.resolve();
|
|
185
|
+
constructor(store, spec) {
|
|
186
|
+
this.store = store;
|
|
187
|
+
this.spec = spec;
|
|
188
|
+
}
|
|
189
|
+
getSnapshot = () => this.#state;
|
|
190
|
+
subscribe = (listener) => {
|
|
191
|
+
this.#listeners.add(listener);
|
|
192
|
+
this.#subscribers += 1;
|
|
193
|
+
if (this.#subscribers === 1) {
|
|
194
|
+
if (this.spec.claimCoverage !== false) {
|
|
195
|
+
const claims = [];
|
|
196
|
+
for (const coverage of this.spec.coverage ?? []) {
|
|
197
|
+
claims.push(this.store.setWindowClaim(this.#owner, coverage.base, coverage.units));
|
|
198
|
+
}
|
|
199
|
+
this.#claimReady = Promise.all(claims).then(() => undefined);
|
|
200
|
+
}
|
|
201
|
+
this.#requestRead();
|
|
202
|
+
}
|
|
203
|
+
return () => {
|
|
204
|
+
if (!this.#listeners.delete(listener))
|
|
205
|
+
return;
|
|
206
|
+
this.#subscribers -= 1;
|
|
207
|
+
if (this.#subscribers === 0)
|
|
208
|
+
this.store.releaseWindowClaims(this.#owner);
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
refresh = () => this.#requestRead(true);
|
|
212
|
+
onChange(batch) {
|
|
213
|
+
if (!batchMatches(batch, this.spec))
|
|
214
|
+
return;
|
|
215
|
+
if (batch.revision > this.#desiredRevision) {
|
|
216
|
+
this.#desiredRevision = batch.revision;
|
|
217
|
+
}
|
|
218
|
+
if (this.#subscribers > 0)
|
|
219
|
+
this.#requestRead();
|
|
220
|
+
}
|
|
221
|
+
#publish(next) {
|
|
222
|
+
if (next.rows === this.#state.rows &&
|
|
223
|
+
next.phase === this.#state.phase &&
|
|
224
|
+
next.error === this.#state.error &&
|
|
225
|
+
next.isRefreshing === this.#state.isRefreshing) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.#state = next;
|
|
229
|
+
for (const listener of this.#listeners)
|
|
230
|
+
listener();
|
|
231
|
+
}
|
|
232
|
+
#requestRead(refreshing = false) {
|
|
233
|
+
this.#requested = true;
|
|
234
|
+
if (refreshing &&
|
|
235
|
+
this.#state.revision !== undefined &&
|
|
236
|
+
!this.#state.isRefreshing) {
|
|
237
|
+
this.#publish({ ...this.#state, isRefreshing: true });
|
|
238
|
+
}
|
|
239
|
+
if (this.#scheduled || this.#running)
|
|
240
|
+
return;
|
|
241
|
+
this.#scheduled = true;
|
|
242
|
+
scheduleMicrotask(() => {
|
|
243
|
+
this.#scheduled = false;
|
|
244
|
+
void this.#readLoop();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
async #readLoop() {
|
|
248
|
+
if (this.#running || this.#subscribers === 0)
|
|
249
|
+
return;
|
|
250
|
+
this.#running = true;
|
|
251
|
+
try {
|
|
252
|
+
do {
|
|
253
|
+
this.#requested = false;
|
|
254
|
+
await this.#claimReady;
|
|
255
|
+
const snapshot = await this.store.client.querySnapshot({
|
|
256
|
+
sql: this.spec.sql,
|
|
257
|
+
...(this.spec.params !== undefined
|
|
258
|
+
? { params: this.spec.params }
|
|
259
|
+
: {}),
|
|
260
|
+
...(this.spec.coverage !== undefined
|
|
261
|
+
? { coverage: this.spec.coverage }
|
|
262
|
+
: {}),
|
|
263
|
+
});
|
|
264
|
+
if (snapshot.revision < this.#desiredRevision) {
|
|
265
|
+
this.#requested = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const rows = reconcileRows(this.#state.rows, snapshot.rows, this.spec.rowKey);
|
|
269
|
+
const phase = snapshot.coverage.complete
|
|
270
|
+
? 'ready'
|
|
271
|
+
: rows.length > 0
|
|
272
|
+
? 'partial'
|
|
273
|
+
: 'loading';
|
|
274
|
+
this.#publish({
|
|
275
|
+
rows,
|
|
276
|
+
phase,
|
|
277
|
+
revision: snapshot.revision,
|
|
278
|
+
error: undefined,
|
|
279
|
+
isRefreshing: false,
|
|
280
|
+
});
|
|
281
|
+
} while (this.#requested && this.#subscribers > 0);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
const wrapped = errorOf(error);
|
|
285
|
+
this.#publish({
|
|
286
|
+
...this.#state,
|
|
287
|
+
phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
|
|
288
|
+
error: wrapped,
|
|
289
|
+
isRefreshing: false,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
this.#running = false;
|
|
294
|
+
if (this.#requested && this.#subscribers > 0)
|
|
295
|
+
this.#requestRead();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
class ValueEntry {
|
|
300
|
+
value;
|
|
301
|
+
read;
|
|
302
|
+
#listeners = new Set();
|
|
303
|
+
constructor(value, read) {
|
|
304
|
+
this.value = value;
|
|
305
|
+
this.read = read;
|
|
306
|
+
}
|
|
307
|
+
getSnapshot = () => this.value;
|
|
308
|
+
subscribe = (listener) => {
|
|
309
|
+
this.#listeners.add(listener);
|
|
310
|
+
return () => this.#listeners.delete(listener);
|
|
311
|
+
};
|
|
312
|
+
refresh = () => {
|
|
313
|
+
void this.read().then((next) => this.set(next));
|
|
314
|
+
};
|
|
315
|
+
set(next) {
|
|
316
|
+
if (next === this.value)
|
|
317
|
+
return;
|
|
318
|
+
this.value = next;
|
|
319
|
+
for (const listener of this.#listeners)
|
|
320
|
+
listener();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
class WindowEntry {
|
|
324
|
+
store;
|
|
325
|
+
base;
|
|
326
|
+
baseKey;
|
|
327
|
+
#listeners = new Set();
|
|
328
|
+
#state = { units: [], pending: [] };
|
|
329
|
+
#running = false;
|
|
330
|
+
#requested = false;
|
|
331
|
+
constructor(store, base, baseKey) {
|
|
332
|
+
this.store = store;
|
|
333
|
+
this.base = base;
|
|
334
|
+
this.baseKey = baseKey;
|
|
335
|
+
}
|
|
336
|
+
getSnapshot = () => this.#state;
|
|
337
|
+
subscribe = (listener) => {
|
|
338
|
+
this.#listeners.add(listener);
|
|
339
|
+
if (this.#listeners.size === 1)
|
|
340
|
+
this.refresh();
|
|
341
|
+
return () => this.#listeners.delete(listener);
|
|
342
|
+
};
|
|
343
|
+
refresh = () => {
|
|
344
|
+
this.#requested = true;
|
|
345
|
+
if (this.#running)
|
|
346
|
+
return;
|
|
347
|
+
void this.#readLoop();
|
|
348
|
+
};
|
|
349
|
+
onChange(batch) {
|
|
350
|
+
if (batch.windows.some((change) => change.baseKey === this.baseKey) &&
|
|
351
|
+
this.#listeners.size > 0) {
|
|
352
|
+
this.refresh();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async #readLoop() {
|
|
356
|
+
this.#running = true;
|
|
357
|
+
try {
|
|
358
|
+
do {
|
|
359
|
+
this.#requested = false;
|
|
360
|
+
const next = await this.store.client.windowState(this.base);
|
|
361
|
+
if (canonicalValue(next.units) !== canonicalValue(this.#state.units) ||
|
|
362
|
+
canonicalValue(next.pending) !== canonicalValue(this.#state.pending)) {
|
|
363
|
+
this.#state = next;
|
|
364
|
+
for (const listener of this.#listeners)
|
|
365
|
+
listener();
|
|
366
|
+
}
|
|
367
|
+
} while (this.#requested);
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// WindowState predates the error-bearing query result. Keep the last
|
|
371
|
+
// coherent snapshot; a later exact window event or refresh retries.
|
|
372
|
+
}
|
|
373
|
+
finally {
|
|
374
|
+
this.#running = false;
|
|
375
|
+
if (this.#requested)
|
|
376
|
+
this.refresh();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export class ReactiveClientStore {
|
|
381
|
+
client;
|
|
382
|
+
#queries = new Map();
|
|
383
|
+
#windows = new Map();
|
|
384
|
+
#windowClaims = new Map();
|
|
385
|
+
#offChange;
|
|
386
|
+
status;
|
|
387
|
+
conflicts;
|
|
388
|
+
constructor(client) {
|
|
389
|
+
this.client = client;
|
|
390
|
+
const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
|
|
391
|
+
try {
|
|
392
|
+
return {
|
|
393
|
+
status: await client.statusSnapshot(),
|
|
394
|
+
error: undefined,
|
|
395
|
+
isLoading: false,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
return { status: undefined, error: errorOf(error), isLoading: false };
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
const conflicts = new ValueEntry({ conflicts: [], rejections: [], error: undefined, isLoading: true }, async () => {
|
|
403
|
+
try {
|
|
404
|
+
const [found, rejected] = await Promise.all([
|
|
405
|
+
readCollection(client.conflicts),
|
|
406
|
+
readCollection(client.rejections),
|
|
407
|
+
]);
|
|
408
|
+
return {
|
|
409
|
+
conflicts: found,
|
|
410
|
+
rejections: rejected,
|
|
411
|
+
error: undefined,
|
|
412
|
+
isLoading: false,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
conflicts: [],
|
|
418
|
+
rejections: [],
|
|
419
|
+
error: errorOf(error),
|
|
420
|
+
isLoading: false,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
this.status = status;
|
|
425
|
+
this.conflicts = conflicts;
|
|
426
|
+
status.refresh();
|
|
427
|
+
conflicts.refresh();
|
|
428
|
+
this.start();
|
|
429
|
+
}
|
|
430
|
+
query(spec) {
|
|
431
|
+
const dependencies = spec.dependencies.map((dependency) => ({
|
|
432
|
+
table: dependency.table,
|
|
433
|
+
...(dependency.scopeKeys === undefined
|
|
434
|
+
? {}
|
|
435
|
+
: { scopeKeys: [...new Set(dependency.scopeKeys)].sort() }),
|
|
436
|
+
}));
|
|
437
|
+
const coverage = (spec.coverage ?? []).map((item) => ({
|
|
438
|
+
baseKey: windowBaseKey(item.base),
|
|
439
|
+
units: [...new Set(item.units)].sort(),
|
|
440
|
+
}));
|
|
441
|
+
const key = canonicalValue({
|
|
442
|
+
id: spec.id,
|
|
443
|
+
sql: spec.sql,
|
|
444
|
+
params: spec.params ?? [],
|
|
445
|
+
dependencies,
|
|
446
|
+
coverage,
|
|
447
|
+
claimCoverage: spec.claimCoverage !== false,
|
|
448
|
+
});
|
|
449
|
+
let entry = this.#queries.get(key);
|
|
450
|
+
if (entry === undefined) {
|
|
451
|
+
entry = new QueryEntry(this, spec);
|
|
452
|
+
this.#queries.set(key, entry);
|
|
453
|
+
}
|
|
454
|
+
return entry;
|
|
455
|
+
}
|
|
456
|
+
/** Retain a composable window working set outside React. The returned
|
|
457
|
+
* handle exposes registration completion and releases only this owner. */
|
|
458
|
+
retainWindow(base, units) {
|
|
459
|
+
const owner = Symbol('retained-window');
|
|
460
|
+
const ready = this.setWindowClaim(owner, base, units);
|
|
461
|
+
let active = true;
|
|
462
|
+
return {
|
|
463
|
+
ready,
|
|
464
|
+
release: () => {
|
|
465
|
+
if (!active)
|
|
466
|
+
return;
|
|
467
|
+
active = false;
|
|
468
|
+
this.releaseWindowClaims(owner);
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
window(base) {
|
|
473
|
+
const key = windowBaseKey(base);
|
|
474
|
+
let entry = this.#windows.get(key);
|
|
475
|
+
if (entry === undefined) {
|
|
476
|
+
entry = new WindowEntry(this, base, key);
|
|
477
|
+
this.#windows.set(key, entry);
|
|
478
|
+
}
|
|
479
|
+
return entry;
|
|
480
|
+
}
|
|
481
|
+
setWindowClaim(owner, base, units) {
|
|
482
|
+
const key = windowBaseKey(base);
|
|
483
|
+
let group = this.#windowClaims.get(key);
|
|
484
|
+
if (group === undefined) {
|
|
485
|
+
group = {
|
|
486
|
+
base,
|
|
487
|
+
claims: new Map(),
|
|
488
|
+
appliedKey: '',
|
|
489
|
+
scheduled: false,
|
|
490
|
+
running: false,
|
|
491
|
+
requested: false,
|
|
492
|
+
waiters: [],
|
|
493
|
+
};
|
|
494
|
+
this.#windowClaims.set(key, group);
|
|
495
|
+
}
|
|
496
|
+
group.claims.set(owner, new Set(units));
|
|
497
|
+
const result = new Promise((resolve, reject) => {
|
|
498
|
+
group?.waiters.push({ resolve, reject });
|
|
499
|
+
});
|
|
500
|
+
this.#scheduleWindow(group);
|
|
501
|
+
return result;
|
|
502
|
+
}
|
|
503
|
+
releaseWindowClaims(owner) {
|
|
504
|
+
for (const group of this.#windowClaims.values()) {
|
|
505
|
+
if (group.claims.delete(owner))
|
|
506
|
+
this.#scheduleWindow(group);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
#scheduleWindow(group) {
|
|
510
|
+
group.requested = true;
|
|
511
|
+
if (group.scheduled || group.running)
|
|
512
|
+
return;
|
|
513
|
+
group.scheduled = true;
|
|
514
|
+
scheduleMicrotask(() => {
|
|
515
|
+
group.scheduled = false;
|
|
516
|
+
void this.#flushWindow(group);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async #flushWindow(group) {
|
|
520
|
+
if (group.running)
|
|
521
|
+
return;
|
|
522
|
+
group.running = true;
|
|
523
|
+
try {
|
|
524
|
+
while (group.requested) {
|
|
525
|
+
group.requested = false;
|
|
526
|
+
const units = [
|
|
527
|
+
...new Set([...group.claims.values()].flatMap((set) => [...set])),
|
|
528
|
+
].sort();
|
|
529
|
+
const key = canonicalValue(units);
|
|
530
|
+
if (key !== group.appliedKey) {
|
|
531
|
+
await this.client.setWindow(group.base, units);
|
|
532
|
+
group.appliedKey = key;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
for (const waiter of group.waiters.splice(0))
|
|
536
|
+
waiter.resolve();
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
for (const waiter of group.waiters.splice(0))
|
|
540
|
+
waiter.reject(error);
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
group.running = false;
|
|
544
|
+
if (group.requested)
|
|
545
|
+
this.#scheduleWindow(group);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
start() {
|
|
549
|
+
if (this.#offChange !== undefined)
|
|
550
|
+
return;
|
|
551
|
+
this.#offChange = this.client.onChange((batch) => {
|
|
552
|
+
for (const entry of this.#queries.values())
|
|
553
|
+
entry.onChange(batch);
|
|
554
|
+
for (const entry of this.#windows.values())
|
|
555
|
+
entry.onChange(batch);
|
|
556
|
+
if (batch.status !== undefined) {
|
|
557
|
+
this.status.set({
|
|
558
|
+
status: batch.status,
|
|
559
|
+
error: undefined,
|
|
560
|
+
isLoading: false,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (batch.conflictsChanged || batch.rejectionsChanged) {
|
|
564
|
+
this.conflicts.refresh();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
dispose() {
|
|
569
|
+
this.#offChange?.();
|
|
570
|
+
this.#offChange = undefined;
|
|
571
|
+
for (const group of this.#windowClaims.values()) {
|
|
572
|
+
void Promise.resolve(this.client.setWindow(group.base, []));
|
|
573
|
+
}
|
|
574
|
+
this.#windowClaims.clear();
|
|
575
|
+
}
|
|
576
|
+
}
|
package/dist/schema.js
CHANGED
|
@@ -178,6 +178,7 @@ export function ensureLocalSchema(db, schema) {
|
|
|
178
178
|
}
|
|
179
179
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
180
180
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
181
|
+
db.exec(`INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`);
|
|
181
182
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
182
183
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
184
|
client_commit_id TEXT NOT NULL UNIQUE,
|
package/dist/state.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ScopeMap } from '@syncular/core';
|
|
8
8
|
import type { ClientDatabase } from './database.js';
|
|
9
|
+
import type { LocalRevision } from './invalidation.js';
|
|
10
|
+
export declare const LOCAL_REVISION_KEY = "localRevision";
|
|
9
11
|
export type SubscriptionStatus = 'active' | 'revoked' | 'failed';
|
|
10
12
|
export interface SubscriptionRecord {
|
|
11
13
|
readonly id: string;
|
|
@@ -38,3 +40,10 @@ export declare function deleteSubscription(db: ClientDatabase, id: string): void
|
|
|
38
40
|
export declare function resetSubscriptionsForBump(db: ClientDatabase): void;
|
|
39
41
|
export declare function getMeta(db: ClientDatabase, key: string): string | undefined;
|
|
40
42
|
export declare function setMeta(db: ClientDatabase, key: string, value: string): void;
|
|
43
|
+
/** Read the durable client-local observer revision (SPEC §7.5). */
|
|
44
|
+
export declare function getLocalRevision(db: ClientDatabase): LocalRevision;
|
|
45
|
+
/**
|
|
46
|
+
* Increment the durable revision. The caller MUST own the same transaction as
|
|
47
|
+
* the observer-visible writes represented by the corresponding change batch.
|
|
48
|
+
*/
|
|
49
|
+
export declare function bumpLocalRevision(db: ClientDatabase): LocalRevision;
|
package/dist/state.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export const LOCAL_REVISION_KEY = 'localRevision';
|
|
2
|
+
const MAX_U64 = 18446744073709551615n;
|
|
1
3
|
function rowToRecord(row) {
|
|
2
4
|
return {
|
|
3
5
|
id: row.id,
|
|
@@ -75,3 +77,30 @@ export function setMeta(db, key, value) {
|
|
|
75
77
|
value,
|
|
76
78
|
]);
|
|
77
79
|
}
|
|
80
|
+
/** Read the durable client-local observer revision (SPEC §7.5). */
|
|
81
|
+
export function getLocalRevision(db) {
|
|
82
|
+
const raw = getMeta(db, LOCAL_REVISION_KEY);
|
|
83
|
+
if (raw === undefined)
|
|
84
|
+
return 0n;
|
|
85
|
+
if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
86
|
+
throw new Error(`invalid persisted local revision ${JSON.stringify(raw)}`);
|
|
87
|
+
}
|
|
88
|
+
const revision = BigInt(raw);
|
|
89
|
+
if (revision > MAX_U64) {
|
|
90
|
+
throw new Error(`persisted local revision exceeds u64: ${raw}`);
|
|
91
|
+
}
|
|
92
|
+
return revision;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Increment the durable revision. The caller MUST own the same transaction as
|
|
96
|
+
* the observer-visible writes represented by the corresponding change batch.
|
|
97
|
+
*/
|
|
98
|
+
export function bumpLocalRevision(db) {
|
|
99
|
+
const current = getLocalRevision(db);
|
|
100
|
+
if (current === MAX_U64) {
|
|
101
|
+
throw new Error('local revision exhausted u64');
|
|
102
|
+
}
|
|
103
|
+
const next = current + 1n;
|
|
104
|
+
setMeta(db, LOCAL_REVISION_KEY, next.toString());
|
|
105
|
+
return next;
|
|
106
|
+
}
|
package/dist/window.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface WindowBase {
|
|
|
25
25
|
/** The scope variable whose values are the window units. */
|
|
26
26
|
readonly variable: string;
|
|
27
27
|
/** Scopes shared by every unit (other variables), if any. */
|
|
28
|
-
readonly fixedScopes?:
|
|
28
|
+
readonly fixedScopes?: Readonly<Record<string, readonly string[]>>;
|
|
29
29
|
readonly params?: string;
|
|
30
30
|
}
|
|
31
31
|
/** A live unit in the registry: its value and the subscription it drives. */
|
|
@@ -33,6 +33,9 @@ export interface WindowUnit {
|
|
|
33
33
|
readonly unit: string;
|
|
34
34
|
readonly subId: string;
|
|
35
35
|
}
|
|
36
|
+
export interface RegisteredWindowUnit extends WindowUnit {
|
|
37
|
+
readonly baseKey: string;
|
|
38
|
+
}
|
|
36
39
|
/**
|
|
37
40
|
* A stable, server-opaque key for a window base — table + variable +
|
|
38
41
|
* canonical fixed scopes. Two `setWindow` calls with the same base
|
|
@@ -50,6 +53,8 @@ export declare function unitScopes(base: WindowBase, unit: string): ScopeMap;
|
|
|
50
53
|
export declare function deriveSubId(base: WindowBase, unit: string): Promise<string>;
|
|
51
54
|
/** Live units for a base, ordered by unit value. */
|
|
52
55
|
export declare function loadWindowUnits(db: ClientDatabase, baseKey: string): WindowUnit[];
|
|
56
|
+
/** Registry lookup used to emit exact completion changes for a sub id. */
|
|
57
|
+
export declare function getWindowUnitBySubId(db: ClientDatabase, subId: string): RegisteredWindowUnit | undefined;
|
|
53
58
|
export declare function insertWindowUnit(db: ClientDatabase, baseKey: string, unit: string, subId: string): void;
|
|
54
59
|
export declare function deleteWindowUnit(db: ClientDatabase, baseKey: string, unit: string): void;
|
|
55
60
|
/** Is a single scope value windowed-in for this base? (the oracle, I3) */
|
package/dist/window.js
CHANGED
|
Binary file
|