@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,695 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
QueryReadSpec,
|
|
3
|
+
QuerySnapshot,
|
|
4
|
+
WindowCoverage,
|
|
5
|
+
WindowState,
|
|
6
|
+
} from './client';
|
|
7
|
+
import type { SqlValue } from './database';
|
|
8
|
+
import type {
|
|
9
|
+
ClientChangeBatch,
|
|
10
|
+
ClientChangeListener,
|
|
11
|
+
SyncStatusSnapshot,
|
|
12
|
+
} from './invalidation';
|
|
13
|
+
import { type WindowBase, windowBaseKey } from './window';
|
|
14
|
+
|
|
15
|
+
export interface QueryDependency {
|
|
16
|
+
readonly table: string;
|
|
17
|
+
readonly scopeKeys?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReactiveQuerySpec<Row> {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly sql: string;
|
|
23
|
+
readonly params?: readonly SqlValue[];
|
|
24
|
+
readonly dependencies: readonly QueryDependency[];
|
|
25
|
+
readonly coverage?: readonly WindowCoverage[];
|
|
26
|
+
readonly rowKey?: (row: Row) => readonly SqlValue[];
|
|
27
|
+
readonly claimCoverage?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
|
|
31
|
+
|
|
32
|
+
export interface LiveQueryResult<Row> {
|
|
33
|
+
readonly rows: readonly Row[];
|
|
34
|
+
readonly phase: LiveQueryPhase;
|
|
35
|
+
readonly revision: bigint | undefined;
|
|
36
|
+
readonly error: Error | undefined;
|
|
37
|
+
readonly isRefreshing: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ReactiveQueryClient {
|
|
41
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
42
|
+
querySnapshot<Row = Record<string, SqlValue>>(
|
|
43
|
+
spec: QueryReadSpec,
|
|
44
|
+
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
45
|
+
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
46
|
+
readonly conflicts:
|
|
47
|
+
| readonly unknown[]
|
|
48
|
+
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
49
|
+
readonly rejections:
|
|
50
|
+
| readonly unknown[]
|
|
51
|
+
| (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
52
|
+
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
53
|
+
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ExternalStoreEntry<T> {
|
|
57
|
+
subscribe(listener: () => void): () => void;
|
|
58
|
+
getSnapshot(): T;
|
|
59
|
+
refresh(): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WindowRetention {
|
|
63
|
+
/** Resolves once this retained claim has reached the core window. */
|
|
64
|
+
readonly ready: Promise<void>;
|
|
65
|
+
/** Release only this retention owner's units from the composable union. */
|
|
66
|
+
readonly release: () => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StatusStoreSnapshot {
|
|
70
|
+
readonly status: SyncStatusSnapshot | undefined;
|
|
71
|
+
readonly error: Error | undefined;
|
|
72
|
+
readonly isLoading: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ConflictStoreSnapshot<
|
|
76
|
+
Conflict = unknown,
|
|
77
|
+
Rejection = unknown,
|
|
78
|
+
> {
|
|
79
|
+
readonly conflicts: readonly Conflict[];
|
|
80
|
+
readonly rejections: readonly Rejection[];
|
|
81
|
+
readonly error: Error | undefined;
|
|
82
|
+
readonly isLoading: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function errorOf(value: unknown): Error {
|
|
86
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readCollection(
|
|
90
|
+
value:
|
|
91
|
+
| readonly unknown[]
|
|
92
|
+
| (() => readonly unknown[] | Promise<readonly unknown[]>),
|
|
93
|
+
): readonly unknown[] | Promise<readonly unknown[]> {
|
|
94
|
+
return typeof value === 'function' ? value() : value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function unsupportedCanonicalValue(value: unknown): never {
|
|
98
|
+
const description = Object.prototype.toString.call(value);
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`unsupported reactive cache-key value ${description}; use null, string, finite number, bigint, boolean, bytes, arrays, or plain objects`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function encodeCanonical(value: unknown, stack: Set<object>): string {
|
|
105
|
+
if (value === null) return 'n';
|
|
106
|
+
if (typeof value === 'string') return `s${value.length}:${value}`;
|
|
107
|
+
if (typeof value === 'number') {
|
|
108
|
+
if (!Number.isFinite(value)) return unsupportedCanonicalValue(value);
|
|
109
|
+
if (Object.is(value, -0)) return 'd-0';
|
|
110
|
+
return `d${value}`;
|
|
111
|
+
}
|
|
112
|
+
if (typeof value === 'bigint') return `i${value}`;
|
|
113
|
+
if (typeof value === 'boolean') return value ? 'b1' : 'b0';
|
|
114
|
+
if (value instanceof Uint8Array) {
|
|
115
|
+
let hex = '';
|
|
116
|
+
for (const byte of value) hex += byte.toString(16).padStart(2, '0');
|
|
117
|
+
return `x${hex}`;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(value)) {
|
|
120
|
+
if (stack.has(value)) return unsupportedCanonicalValue(value);
|
|
121
|
+
stack.add(value);
|
|
122
|
+
const encoded = `a${value.length}[${value
|
|
123
|
+
.map((member) => encodeCanonical(member, stack))
|
|
124
|
+
.join('')}]`;
|
|
125
|
+
stack.delete(value);
|
|
126
|
+
return encoded;
|
|
127
|
+
}
|
|
128
|
+
if (typeof value === 'object') {
|
|
129
|
+
const prototype = Object.getPrototypeOf(value);
|
|
130
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
131
|
+
return unsupportedCanonicalValue(value);
|
|
132
|
+
}
|
|
133
|
+
if (stack.has(value)) return unsupportedCanonicalValue(value);
|
|
134
|
+
stack.add(value);
|
|
135
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
136
|
+
([left], [right]) => (left < right ? -1 : left > right ? 1 : 0),
|
|
137
|
+
);
|
|
138
|
+
const encoded = `o${entries.length}{${entries
|
|
139
|
+
.map(
|
|
140
|
+
([key, member]) =>
|
|
141
|
+
`${encodeCanonical(key, stack)}${encodeCanonical(member, stack)}`,
|
|
142
|
+
)
|
|
143
|
+
.join('')}}`;
|
|
144
|
+
stack.delete(value);
|
|
145
|
+
return encoded;
|
|
146
|
+
}
|
|
147
|
+
return unsupportedCanonicalValue(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Lossless deterministic identity for query params, bytes, and row keys. */
|
|
151
|
+
export function canonicalValue(value: unknown): string {
|
|
152
|
+
return encodeCanonical(value, new Set());
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scheduleMicrotask(task: () => void): void {
|
|
156
|
+
if (typeof queueMicrotask === 'function') queueMicrotask(task);
|
|
157
|
+
else void Promise.resolve().then(task);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function batchMatches<Row>(
|
|
161
|
+
batch: ClientChangeBatch,
|
|
162
|
+
spec: ReactiveQuerySpec<Row>,
|
|
163
|
+
): boolean {
|
|
164
|
+
for (const change of batch.tables) {
|
|
165
|
+
const dependency = spec.dependencies.find(
|
|
166
|
+
(candidate) => candidate.table === change.table,
|
|
167
|
+
);
|
|
168
|
+
if (dependency === undefined) continue;
|
|
169
|
+
if (dependency.scopeKeys === undefined || change.scopeKeys === undefined) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
for (const key of dependency.scopeKeys) {
|
|
173
|
+
if (change.scopeKeys.has(key)) return true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const change of batch.windows) {
|
|
177
|
+
for (const coverage of spec.coverage ?? []) {
|
|
178
|
+
if (windowBaseKey(coverage.base) !== change.baseKey) continue;
|
|
179
|
+
if (coverage.units.some((unit) => change.units.has(unit))) return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function valuesEqual(left: unknown, right: unknown): boolean {
|
|
186
|
+
if (Object.is(left, right)) return true;
|
|
187
|
+
if (left instanceof Uint8Array || right instanceof Uint8Array) {
|
|
188
|
+
if (!(left instanceof Uint8Array) || !(right instanceof Uint8Array)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (left.byteLength !== right.byteLength) return false;
|
|
192
|
+
for (let index = 0; index < left.byteLength; index += 1) {
|
|
193
|
+
if (left[index] !== right[index]) return false;
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
198
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
199
|
+
return (
|
|
200
|
+
left.length === right.length &&
|
|
201
|
+
left.every((member, index) => valuesEqual(member, right[index]))
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (
|
|
205
|
+
left === null ||
|
|
206
|
+
right === null ||
|
|
207
|
+
typeof left !== 'object' ||
|
|
208
|
+
typeof right !== 'object'
|
|
209
|
+
) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
const leftRecord = left as Record<string, unknown>;
|
|
213
|
+
const rightRecord = right as Record<string, unknown>;
|
|
214
|
+
const keys = Object.keys(leftRecord);
|
|
215
|
+
if (keys.length !== Object.keys(rightRecord).length) return false;
|
|
216
|
+
return keys.every(
|
|
217
|
+
(key) =>
|
|
218
|
+
Object.hasOwn(rightRecord, key) &&
|
|
219
|
+
valuesEqual(leftRecord[key], rightRecord[key]),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function reconcileRows<Row>(
|
|
224
|
+
previous: readonly Row[],
|
|
225
|
+
fresh: readonly Row[],
|
|
226
|
+
rowKey: ((row: Row) => readonly SqlValue[]) | undefined,
|
|
227
|
+
): readonly Row[] {
|
|
228
|
+
if (previous.length === 0) return fresh;
|
|
229
|
+
if (rowKey === undefined) {
|
|
230
|
+
let changed = previous.length !== fresh.length;
|
|
231
|
+
const next = fresh.map((row, index) => {
|
|
232
|
+
const prior = previous[index];
|
|
233
|
+
if (prior !== undefined && valuesEqual(prior, row)) return prior;
|
|
234
|
+
changed = true;
|
|
235
|
+
return row;
|
|
236
|
+
});
|
|
237
|
+
return changed ? next : previous;
|
|
238
|
+
}
|
|
239
|
+
const priorByKey = new Map<string, Row>();
|
|
240
|
+
let duplicate = false;
|
|
241
|
+
for (const row of previous) {
|
|
242
|
+
const key = canonicalValue(rowKey(row));
|
|
243
|
+
if (priorByKey.has(key)) duplicate = true;
|
|
244
|
+
priorByKey.set(key, row);
|
|
245
|
+
}
|
|
246
|
+
const seen = new Set<string>();
|
|
247
|
+
const next = fresh.map((row) => {
|
|
248
|
+
const key = canonicalValue(rowKey(row));
|
|
249
|
+
if (seen.has(key)) duplicate = true;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
const prior = priorByKey.get(key);
|
|
252
|
+
return prior !== undefined && valuesEqual(prior, row) ? prior : row;
|
|
253
|
+
});
|
|
254
|
+
if (duplicate) return reconcileRows(previous, fresh, undefined);
|
|
255
|
+
return next.length === previous.length &&
|
|
256
|
+
next.every((row, i) => row === previous[i])
|
|
257
|
+
? previous
|
|
258
|
+
: next;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class QueryEntry<Row> implements ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
262
|
+
readonly #owner = Symbol('query-window-claim');
|
|
263
|
+
readonly #listeners = new Set<() => void>();
|
|
264
|
+
#state: LiveQueryResult<Row> = {
|
|
265
|
+
rows: [],
|
|
266
|
+
phase: 'loading',
|
|
267
|
+
revision: undefined,
|
|
268
|
+
error: undefined,
|
|
269
|
+
isRefreshing: false,
|
|
270
|
+
};
|
|
271
|
+
#subscribers = 0;
|
|
272
|
+
#scheduled = false;
|
|
273
|
+
#running = false;
|
|
274
|
+
#requested = false;
|
|
275
|
+
#desiredRevision = 0n;
|
|
276
|
+
#claimReady: Promise<void> = Promise.resolve();
|
|
277
|
+
|
|
278
|
+
constructor(
|
|
279
|
+
readonly store: ReactiveClientStore,
|
|
280
|
+
readonly spec: ReactiveQuerySpec<Row>,
|
|
281
|
+
) {}
|
|
282
|
+
|
|
283
|
+
getSnapshot = (): LiveQueryResult<Row> => this.#state;
|
|
284
|
+
|
|
285
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
286
|
+
this.#listeners.add(listener);
|
|
287
|
+
this.#subscribers += 1;
|
|
288
|
+
if (this.#subscribers === 1) {
|
|
289
|
+
if (this.spec.claimCoverage !== false) {
|
|
290
|
+
const claims: Promise<void>[] = [];
|
|
291
|
+
for (const coverage of this.spec.coverage ?? []) {
|
|
292
|
+
claims.push(
|
|
293
|
+
this.store.setWindowClaim(
|
|
294
|
+
this.#owner,
|
|
295
|
+
coverage.base,
|
|
296
|
+
coverage.units,
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
this.#claimReady = Promise.all(claims).then(() => undefined);
|
|
301
|
+
}
|
|
302
|
+
this.#requestRead();
|
|
303
|
+
}
|
|
304
|
+
return () => {
|
|
305
|
+
if (!this.#listeners.delete(listener)) return;
|
|
306
|
+
this.#subscribers -= 1;
|
|
307
|
+
if (this.#subscribers === 0) this.store.releaseWindowClaims(this.#owner);
|
|
308
|
+
};
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
refresh = (): void => this.#requestRead(true);
|
|
312
|
+
|
|
313
|
+
onChange(batch: ClientChangeBatch): void {
|
|
314
|
+
if (!batchMatches(batch, this.spec)) return;
|
|
315
|
+
if (batch.revision > this.#desiredRevision) {
|
|
316
|
+
this.#desiredRevision = batch.revision;
|
|
317
|
+
}
|
|
318
|
+
if (this.#subscribers > 0) this.#requestRead();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#publish(next: LiveQueryResult<Row>): void {
|
|
322
|
+
if (
|
|
323
|
+
next.rows === this.#state.rows &&
|
|
324
|
+
next.phase === this.#state.phase &&
|
|
325
|
+
next.error === this.#state.error &&
|
|
326
|
+
next.isRefreshing === this.#state.isRefreshing
|
|
327
|
+
) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.#state = next;
|
|
331
|
+
for (const listener of this.#listeners) listener();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#requestRead(refreshing = false): void {
|
|
335
|
+
this.#requested = true;
|
|
336
|
+
if (
|
|
337
|
+
refreshing &&
|
|
338
|
+
this.#state.revision !== undefined &&
|
|
339
|
+
!this.#state.isRefreshing
|
|
340
|
+
) {
|
|
341
|
+
this.#publish({ ...this.#state, isRefreshing: true });
|
|
342
|
+
}
|
|
343
|
+
if (this.#scheduled || this.#running) return;
|
|
344
|
+
this.#scheduled = true;
|
|
345
|
+
scheduleMicrotask(() => {
|
|
346
|
+
this.#scheduled = false;
|
|
347
|
+
void this.#readLoop();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async #readLoop(): Promise<void> {
|
|
352
|
+
if (this.#running || this.#subscribers === 0) return;
|
|
353
|
+
this.#running = true;
|
|
354
|
+
try {
|
|
355
|
+
do {
|
|
356
|
+
this.#requested = false;
|
|
357
|
+
await this.#claimReady;
|
|
358
|
+
const snapshot = await this.store.client.querySnapshot<Row>({
|
|
359
|
+
sql: this.spec.sql,
|
|
360
|
+
...(this.spec.params !== undefined
|
|
361
|
+
? { params: this.spec.params }
|
|
362
|
+
: {}),
|
|
363
|
+
...(this.spec.coverage !== undefined
|
|
364
|
+
? { coverage: this.spec.coverage }
|
|
365
|
+
: {}),
|
|
366
|
+
});
|
|
367
|
+
if (snapshot.revision < this.#desiredRevision) {
|
|
368
|
+
this.#requested = true;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const rows = reconcileRows(
|
|
372
|
+
this.#state.rows,
|
|
373
|
+
snapshot.rows,
|
|
374
|
+
this.spec.rowKey,
|
|
375
|
+
);
|
|
376
|
+
const phase: LiveQueryPhase = snapshot.coverage.complete
|
|
377
|
+
? 'ready'
|
|
378
|
+
: rows.length > 0
|
|
379
|
+
? 'partial'
|
|
380
|
+
: 'loading';
|
|
381
|
+
this.#publish({
|
|
382
|
+
rows,
|
|
383
|
+
phase,
|
|
384
|
+
revision: snapshot.revision,
|
|
385
|
+
error: undefined,
|
|
386
|
+
isRefreshing: false,
|
|
387
|
+
});
|
|
388
|
+
} while (this.#requested && this.#subscribers > 0);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
const wrapped = errorOf(error);
|
|
391
|
+
this.#publish({
|
|
392
|
+
...this.#state,
|
|
393
|
+
phase: this.#state.revision === undefined ? 'error' : this.#state.phase,
|
|
394
|
+
error: wrapped,
|
|
395
|
+
isRefreshing: false,
|
|
396
|
+
});
|
|
397
|
+
} finally {
|
|
398
|
+
this.#running = false;
|
|
399
|
+
if (this.#requested && this.#subscribers > 0) this.#requestRead();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
class ValueEntry<T> implements ExternalStoreEntry<T> {
|
|
405
|
+
readonly #listeners = new Set<() => void>();
|
|
406
|
+
constructor(
|
|
407
|
+
private value: T,
|
|
408
|
+
private readonly read: () => Promise<T>,
|
|
409
|
+
) {}
|
|
410
|
+
getSnapshot = (): T => this.value;
|
|
411
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
412
|
+
this.#listeners.add(listener);
|
|
413
|
+
return () => this.#listeners.delete(listener);
|
|
414
|
+
};
|
|
415
|
+
refresh = (): void => {
|
|
416
|
+
void this.read().then((next) => this.set(next));
|
|
417
|
+
};
|
|
418
|
+
set(next: T): void {
|
|
419
|
+
if (next === this.value) return;
|
|
420
|
+
this.value = next;
|
|
421
|
+
for (const listener of this.#listeners) listener();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
interface WindowClaimGroup {
|
|
426
|
+
readonly base: WindowBase;
|
|
427
|
+
readonly claims: Map<symbol, ReadonlySet<string>>;
|
|
428
|
+
appliedKey: string;
|
|
429
|
+
scheduled: boolean;
|
|
430
|
+
running: boolean;
|
|
431
|
+
requested: boolean;
|
|
432
|
+
readonly waiters: Array<{
|
|
433
|
+
resolve: () => void;
|
|
434
|
+
reject: (error: unknown) => void;
|
|
435
|
+
}>;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
class WindowEntry implements ExternalStoreEntry<WindowState> {
|
|
439
|
+
readonly #listeners = new Set<() => void>();
|
|
440
|
+
#state: WindowState = { units: [], pending: [] };
|
|
441
|
+
#running = false;
|
|
442
|
+
#requested = false;
|
|
443
|
+
|
|
444
|
+
constructor(
|
|
445
|
+
readonly store: ReactiveClientStore,
|
|
446
|
+
readonly base: WindowBase,
|
|
447
|
+
readonly baseKey: string,
|
|
448
|
+
) {}
|
|
449
|
+
|
|
450
|
+
getSnapshot = (): WindowState => this.#state;
|
|
451
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
452
|
+
this.#listeners.add(listener);
|
|
453
|
+
if (this.#listeners.size === 1) this.refresh();
|
|
454
|
+
return () => this.#listeners.delete(listener);
|
|
455
|
+
};
|
|
456
|
+
refresh = (): void => {
|
|
457
|
+
this.#requested = true;
|
|
458
|
+
if (this.#running) return;
|
|
459
|
+
void this.#readLoop();
|
|
460
|
+
};
|
|
461
|
+
onChange(batch: ClientChangeBatch): void {
|
|
462
|
+
if (
|
|
463
|
+
batch.windows.some((change) => change.baseKey === this.baseKey) &&
|
|
464
|
+
this.#listeners.size > 0
|
|
465
|
+
) {
|
|
466
|
+
this.refresh();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
async #readLoop(): Promise<void> {
|
|
470
|
+
this.#running = true;
|
|
471
|
+
try {
|
|
472
|
+
do {
|
|
473
|
+
this.#requested = false;
|
|
474
|
+
const next = await this.store.client.windowState(this.base);
|
|
475
|
+
if (
|
|
476
|
+
canonicalValue(next.units) !== canonicalValue(this.#state.units) ||
|
|
477
|
+
canonicalValue(next.pending) !== canonicalValue(this.#state.pending)
|
|
478
|
+
) {
|
|
479
|
+
this.#state = next;
|
|
480
|
+
for (const listener of this.#listeners) listener();
|
|
481
|
+
}
|
|
482
|
+
} while (this.#requested);
|
|
483
|
+
} catch {
|
|
484
|
+
// WindowState predates the error-bearing query result. Keep the last
|
|
485
|
+
// coherent snapshot; a later exact window event or refresh retries.
|
|
486
|
+
} finally {
|
|
487
|
+
this.#running = false;
|
|
488
|
+
if (this.#requested) this.refresh();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export class ReactiveClientStore {
|
|
494
|
+
readonly #queries = new Map<string, QueryEntry<unknown>>();
|
|
495
|
+
readonly #windows = new Map<string, WindowEntry>();
|
|
496
|
+
readonly #windowClaims = new Map<string, WindowClaimGroup>();
|
|
497
|
+
#offChange: (() => void) | undefined;
|
|
498
|
+
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
499
|
+
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
500
|
+
|
|
501
|
+
constructor(readonly client: ReactiveQueryClient) {
|
|
502
|
+
const status = new ValueEntry<StatusStoreSnapshot>(
|
|
503
|
+
{ status: undefined, error: undefined, isLoading: true },
|
|
504
|
+
async () => {
|
|
505
|
+
try {
|
|
506
|
+
return {
|
|
507
|
+
status: await client.statusSnapshot(),
|
|
508
|
+
error: undefined,
|
|
509
|
+
isLoading: false,
|
|
510
|
+
};
|
|
511
|
+
} catch (error) {
|
|
512
|
+
return { status: undefined, error: errorOf(error), isLoading: false };
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
const conflicts = new ValueEntry<ConflictStoreSnapshot>(
|
|
517
|
+
{ conflicts: [], rejections: [], error: undefined, isLoading: true },
|
|
518
|
+
async () => {
|
|
519
|
+
try {
|
|
520
|
+
const [found, rejected] = await Promise.all([
|
|
521
|
+
readCollection(client.conflicts),
|
|
522
|
+
readCollection(client.rejections),
|
|
523
|
+
]);
|
|
524
|
+
return {
|
|
525
|
+
conflicts: found,
|
|
526
|
+
rejections: rejected,
|
|
527
|
+
error: undefined,
|
|
528
|
+
isLoading: false,
|
|
529
|
+
};
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return {
|
|
532
|
+
conflicts: [],
|
|
533
|
+
rejections: [],
|
|
534
|
+
error: errorOf(error),
|
|
535
|
+
isLoading: false,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
this.status = status;
|
|
541
|
+
this.conflicts = conflicts;
|
|
542
|
+
status.refresh();
|
|
543
|
+
conflicts.refresh();
|
|
544
|
+
this.start();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
query<Row>(
|
|
548
|
+
spec: ReactiveQuerySpec<Row>,
|
|
549
|
+
): ExternalStoreEntry<LiveQueryResult<Row>> {
|
|
550
|
+
const dependencies = spec.dependencies.map((dependency) => ({
|
|
551
|
+
table: dependency.table,
|
|
552
|
+
...(dependency.scopeKeys === undefined
|
|
553
|
+
? {}
|
|
554
|
+
: { scopeKeys: [...new Set(dependency.scopeKeys)].sort() }),
|
|
555
|
+
}));
|
|
556
|
+
const coverage = (spec.coverage ?? []).map((item) => ({
|
|
557
|
+
baseKey: windowBaseKey(item.base),
|
|
558
|
+
units: [...new Set(item.units)].sort(),
|
|
559
|
+
}));
|
|
560
|
+
const key = canonicalValue({
|
|
561
|
+
id: spec.id,
|
|
562
|
+
sql: spec.sql,
|
|
563
|
+
params: spec.params ?? [],
|
|
564
|
+
dependencies,
|
|
565
|
+
coverage,
|
|
566
|
+
claimCoverage: spec.claimCoverage !== false,
|
|
567
|
+
});
|
|
568
|
+
let entry = this.#queries.get(key) as QueryEntry<Row> | undefined;
|
|
569
|
+
if (entry === undefined) {
|
|
570
|
+
entry = new QueryEntry(this, spec);
|
|
571
|
+
this.#queries.set(key, entry as QueryEntry<unknown>);
|
|
572
|
+
}
|
|
573
|
+
return entry;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Retain a composable window working set outside React. The returned
|
|
577
|
+
* handle exposes registration completion and releases only this owner. */
|
|
578
|
+
retainWindow(base: WindowBase, units: readonly string[]): WindowRetention {
|
|
579
|
+
const owner = Symbol('retained-window');
|
|
580
|
+
const ready = this.setWindowClaim(owner, base, units);
|
|
581
|
+
let active = true;
|
|
582
|
+
return {
|
|
583
|
+
ready,
|
|
584
|
+
release: () => {
|
|
585
|
+
if (!active) return;
|
|
586
|
+
active = false;
|
|
587
|
+
this.releaseWindowClaims(owner);
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
window(base: WindowBase): ExternalStoreEntry<WindowState> {
|
|
593
|
+
const key = windowBaseKey(base);
|
|
594
|
+
let entry = this.#windows.get(key);
|
|
595
|
+
if (entry === undefined) {
|
|
596
|
+
entry = new WindowEntry(this, base, key);
|
|
597
|
+
this.#windows.set(key, entry);
|
|
598
|
+
}
|
|
599
|
+
return entry;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
setWindowClaim(
|
|
603
|
+
owner: symbol,
|
|
604
|
+
base: WindowBase,
|
|
605
|
+
units: readonly string[],
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
const key = windowBaseKey(base);
|
|
608
|
+
let group = this.#windowClaims.get(key);
|
|
609
|
+
if (group === undefined) {
|
|
610
|
+
group = {
|
|
611
|
+
base,
|
|
612
|
+
claims: new Map(),
|
|
613
|
+
appliedKey: '',
|
|
614
|
+
scheduled: false,
|
|
615
|
+
running: false,
|
|
616
|
+
requested: false,
|
|
617
|
+
waiters: [],
|
|
618
|
+
};
|
|
619
|
+
this.#windowClaims.set(key, group);
|
|
620
|
+
}
|
|
621
|
+
group.claims.set(owner, new Set(units));
|
|
622
|
+
const result = new Promise<void>((resolve, reject) => {
|
|
623
|
+
group?.waiters.push({ resolve, reject });
|
|
624
|
+
});
|
|
625
|
+
this.#scheduleWindow(group);
|
|
626
|
+
return result;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
releaseWindowClaims(owner: symbol): void {
|
|
630
|
+
for (const group of this.#windowClaims.values()) {
|
|
631
|
+
if (group.claims.delete(owner)) this.#scheduleWindow(group);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#scheduleWindow(group: WindowClaimGroup): void {
|
|
636
|
+
group.requested = true;
|
|
637
|
+
if (group.scheduled || group.running) return;
|
|
638
|
+
group.scheduled = true;
|
|
639
|
+
scheduleMicrotask(() => {
|
|
640
|
+
group.scheduled = false;
|
|
641
|
+
void this.#flushWindow(group);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async #flushWindow(group: WindowClaimGroup): Promise<void> {
|
|
646
|
+
if (group.running) return;
|
|
647
|
+
group.running = true;
|
|
648
|
+
try {
|
|
649
|
+
while (group.requested) {
|
|
650
|
+
group.requested = false;
|
|
651
|
+
const units = [
|
|
652
|
+
...new Set([...group.claims.values()].flatMap((set) => [...set])),
|
|
653
|
+
].sort();
|
|
654
|
+
const key = canonicalValue(units);
|
|
655
|
+
if (key !== group.appliedKey) {
|
|
656
|
+
await this.client.setWindow(group.base, units);
|
|
657
|
+
group.appliedKey = key;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const waiter of group.waiters.splice(0)) waiter.resolve();
|
|
661
|
+
} catch (error) {
|
|
662
|
+
for (const waiter of group.waiters.splice(0)) waiter.reject(error);
|
|
663
|
+
} finally {
|
|
664
|
+
group.running = false;
|
|
665
|
+
if (group.requested) this.#scheduleWindow(group);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
start(): void {
|
|
670
|
+
if (this.#offChange !== undefined) return;
|
|
671
|
+
this.#offChange = this.client.onChange((batch) => {
|
|
672
|
+
for (const entry of this.#queries.values()) entry.onChange(batch);
|
|
673
|
+
for (const entry of this.#windows.values()) entry.onChange(batch);
|
|
674
|
+
if (batch.status !== undefined) {
|
|
675
|
+
(this.status as ValueEntry<StatusStoreSnapshot>).set({
|
|
676
|
+
status: batch.status,
|
|
677
|
+
error: undefined,
|
|
678
|
+
isLoading: false,
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
if (batch.conflictsChanged || batch.rejectionsChanged) {
|
|
682
|
+
this.conflicts.refresh();
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
dispose(): void {
|
|
688
|
+
this.#offChange?.();
|
|
689
|
+
this.#offChange = undefined;
|
|
690
|
+
for (const group of this.#windowClaims.values()) {
|
|
691
|
+
void Promise.resolve(this.client.setWindow(group.base, []));
|
|
692
|
+
}
|
|
693
|
+
this.#windowClaims.clear();
|
|
694
|
+
}
|
|
695
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -280,6 +280,9 @@ export function ensureLocalSchema(
|
|
|
280
280
|
}
|
|
281
281
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_meta(
|
|
282
282
|
key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
283
|
+
db.exec(
|
|
284
|
+
`INSERT OR IGNORE INTO _syncular_meta(key, value) VALUES ('localRevision', '0')`,
|
|
285
|
+
);
|
|
283
286
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_outbox(
|
|
284
287
|
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
285
288
|
client_commit_id TEXT NOT NULL UNIQUE,
|