@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
package/src/index.ts
CHANGED
package/src/invalidation.ts
CHANGED
|
@@ -1,107 +1,258 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* I1–I4). Every local mutation — `COMMIT` apply, segment apply (rows +
|
|
4
|
-
* sqlite images), optimistic overlay rebuild, revocation purge, schema-bump
|
|
5
|
-
* reset, and (future) window eviction — routes its touched keys through a
|
|
6
|
-
* single {@link Invalidation} accumulator, and the client emits exactly ONE
|
|
7
|
-
* {@link InvalidationEvent} per apply batch (never per row). Live queries
|
|
8
|
-
* subscribe via `SyncClient.onInvalidate` and re-run only when a table they
|
|
9
|
-
* depend on appears.
|
|
2
|
+
* Revisioned client-local observation events (SPEC §7.5 / RFC 0003).
|
|
10
3
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - Segments carry only a table + `scopeDigest`, NOT per-row scope keys, so
|
|
15
|
-
* a segment apply invalidates at the **table** granularity plus the
|
|
16
|
-
* subscription's requested/effective scope keys (the coarsest honest key
|
|
17
|
-
* the wire supports for bulk data).
|
|
18
|
-
* - Purge / reset / optimistic / eviction are keyed by table (and effective
|
|
19
|
-
* scope keys where a scope map is in hand).
|
|
20
|
-
*
|
|
21
|
-
* `tables` is therefore always the reliable floor; `scopeKeys` is a
|
|
22
|
-
* best-effort refinement present where the source carried it. A live query
|
|
23
|
-
* that cannot express its scope footprint keys off `tables` alone.
|
|
4
|
+
* The core records observer domains while it owns the SQLite transaction,
|
|
5
|
+
* increments the persisted local revision in that same transaction, and emits
|
|
6
|
+
* the frozen batch only after commit. Bridges forward this shape verbatim.
|
|
24
7
|
*/
|
|
25
8
|
import type { ScopeMap } from '@syncular/core';
|
|
9
|
+
import type { LeaseState, SchemaFloor } from './client';
|
|
26
10
|
import type { CompiledClientTable } from './schema';
|
|
27
11
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
readonly
|
|
32
|
-
/**
|
|
33
|
-
readonly scopeKeys
|
|
12
|
+
export type LocalRevision = bigint;
|
|
13
|
+
|
|
14
|
+
export interface TableChange {
|
|
15
|
+
readonly table: string;
|
|
16
|
+
/** Undefined means honestly table-wide; an empty set is never emitted. */
|
|
17
|
+
readonly scopeKeys?: ReadonlySet<string>;
|
|
34
18
|
}
|
|
35
19
|
|
|
36
|
-
export
|
|
20
|
+
export interface WindowChange {
|
|
21
|
+
readonly baseKey: string;
|
|
22
|
+
readonly table: string;
|
|
23
|
+
readonly units: ReadonlySet<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SyncStatusSnapshot {
|
|
27
|
+
readonly outbox: number;
|
|
28
|
+
readonly upgrading: boolean;
|
|
29
|
+
readonly leaseState: LeaseState | undefined;
|
|
30
|
+
readonly schemaFloor: SchemaFloor | undefined;
|
|
31
|
+
readonly syncNeeded: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ClientChangeBatch {
|
|
35
|
+
readonly revision: LocalRevision;
|
|
36
|
+
readonly tables: readonly TableChange[];
|
|
37
|
+
readonly windows: readonly WindowChange[];
|
|
38
|
+
readonly status?: SyncStatusSnapshot;
|
|
39
|
+
readonly conflictsChanged: boolean;
|
|
40
|
+
readonly rejectionsChanged: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ClientChangeListener = (batch: ClientChangeBatch) => void;
|
|
44
|
+
|
|
45
|
+
/** Network work created by a core command (SPEC §7.5). */
|
|
46
|
+
export type SyncIntent =
|
|
47
|
+
| { readonly kind: 'none' }
|
|
48
|
+
| { readonly kind: 'interactive' }
|
|
49
|
+
| { readonly kind: 'background'; readonly delayMs: number };
|
|
50
|
+
|
|
51
|
+
export interface CommandEffects {
|
|
52
|
+
readonly sync: SyncIntent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CommandResult<T> {
|
|
56
|
+
readonly value: T;
|
|
57
|
+
readonly effects: CommandEffects;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const NO_COMMAND_EFFECTS: CommandEffects = {
|
|
61
|
+
sync: { kind: 'none' },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
interface MutableTableChange {
|
|
65
|
+
tableWide: boolean;
|
|
66
|
+
scopeKeys: Set<string> | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface MutableWindowChange {
|
|
70
|
+
table: string;
|
|
71
|
+
units: Set<string>;
|
|
72
|
+
}
|
|
37
73
|
|
|
38
74
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* and cheap on purpose — no allocation until something is actually touched.
|
|
75
|
+
* Per-observer-transaction accumulator. It contains no revision itself: the
|
|
76
|
+
* transaction wrapper assigns the revision only after all writes succeeded.
|
|
42
77
|
*/
|
|
43
|
-
export class
|
|
44
|
-
#tables
|
|
45
|
-
#
|
|
78
|
+
export class ChangeAccumulator {
|
|
79
|
+
readonly #tables = new Map<string, MutableTableChange>();
|
|
80
|
+
readonly #windows = new Map<string, MutableWindowChange>();
|
|
81
|
+
#status = false;
|
|
82
|
+
#conflicts = false;
|
|
83
|
+
#rejections = false;
|
|
46
84
|
|
|
47
|
-
/** Mark a whole table dirty
|
|
85
|
+
/** Mark a whole table dirty, discarding any weaker scope-only facts. */
|
|
48
86
|
table(name: string): void {
|
|
49
|
-
|
|
50
|
-
this.#tables.add(name);
|
|
87
|
+
this.#tables.set(name, { tableWide: true, scopeKeys: undefined });
|
|
51
88
|
}
|
|
52
89
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
/** Associate a precise `prefix:value` key with its table. */
|
|
91
|
+
scope(table: string, key: string): void {
|
|
92
|
+
const current = this.#tables.get(table);
|
|
93
|
+
if (current?.tableWide) return;
|
|
94
|
+
if (current === undefined) {
|
|
95
|
+
this.#tables.set(table, {
|
|
96
|
+
tableWide: false,
|
|
97
|
+
scopeKeys: new Set([key]),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
current.scopeKeys?.add(key);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Record registration/completeness change for one window unit. */
|
|
105
|
+
window(baseKey: string, table: string, unit: string): void {
|
|
106
|
+
const current = this.#windows.get(baseKey);
|
|
107
|
+
if (current === undefined) {
|
|
108
|
+
this.#windows.set(baseKey, { table, units: new Set([unit]) });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (current.table !== table) {
|
|
112
|
+
throw new Error(`window base ${JSON.stringify(baseKey)} changed tables`);
|
|
113
|
+
}
|
|
114
|
+
current.units.add(unit);
|
|
57
115
|
}
|
|
58
116
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
117
|
+
status(): void {
|
|
118
|
+
this.#status = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
conflicts(): void {
|
|
122
|
+
this.#conflicts = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
rejections(): void {
|
|
126
|
+
this.#rejections = true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Add precise keys for a requested/effective scope map. */
|
|
64
130
|
scopeMap(table: CompiledClientTable, scopes: ScopeMap): void {
|
|
65
131
|
for (const [variable, values] of Object.entries(scopes)) {
|
|
66
132
|
const prefix = table.scopePrefixByVariable.get(variable);
|
|
67
133
|
if (prefix === undefined) continue;
|
|
68
|
-
for (const
|
|
134
|
+
for (const value of values) this.scope(table.name, `${prefix}:${value}`);
|
|
69
135
|
}
|
|
70
136
|
}
|
|
71
137
|
|
|
72
|
-
/**
|
|
138
|
+
/** Add precise keys for a COMMIT change's stored scope values. */
|
|
73
139
|
changeScopes(
|
|
74
140
|
table: CompiledClientTable,
|
|
75
|
-
scopes: Record<string, string
|
|
141
|
+
scopes: Readonly<Record<string, string>>,
|
|
76
142
|
): void {
|
|
77
143
|
for (const [variable, value] of Object.entries(scopes)) {
|
|
78
144
|
const prefix = table.scopePrefixByVariable.get(variable);
|
|
79
|
-
if (prefix !== undefined)
|
|
145
|
+
if (prefix !== undefined) {
|
|
146
|
+
this.scope(table.name, `${prefix}:${value}`);
|
|
147
|
+
}
|
|
80
148
|
}
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
get touched(): boolean {
|
|
84
|
-
return
|
|
152
|
+
return (
|
|
153
|
+
this.#tables.size > 0 ||
|
|
154
|
+
this.#windows.size > 0 ||
|
|
155
|
+
this.#status ||
|
|
156
|
+
this.#conflicts ||
|
|
157
|
+
this.#rejections
|
|
158
|
+
);
|
|
85
159
|
}
|
|
86
160
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
161
|
+
get statusChanged(): boolean {
|
|
162
|
+
return this.#status;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
finish(
|
|
166
|
+
revision: LocalRevision,
|
|
167
|
+
status: SyncStatusSnapshot | undefined,
|
|
168
|
+
): ClientChangeBatch {
|
|
169
|
+
if (this.#status && status === undefined) {
|
|
170
|
+
throw new Error('status change batch requires a post-commit snapshot');
|
|
171
|
+
}
|
|
172
|
+
const tables: TableChange[] = [];
|
|
173
|
+
for (const [table, change] of this.#tables) {
|
|
174
|
+
tables.push(
|
|
175
|
+
change.tableWide
|
|
176
|
+
? { table }
|
|
177
|
+
: { table, scopeKeys: change.scopeKeys as ReadonlySet<string> },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const windows: WindowChange[] = [];
|
|
181
|
+
for (const [baseKey, change] of this.#windows) {
|
|
182
|
+
windows.push({
|
|
183
|
+
baseKey,
|
|
184
|
+
table: change.table,
|
|
185
|
+
units: change.units,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
90
188
|
return {
|
|
91
|
-
|
|
92
|
-
|
|
189
|
+
revision,
|
|
190
|
+
tables,
|
|
191
|
+
windows,
|
|
192
|
+
...(this.#status ? { status: status as SyncStatusSnapshot } : {}),
|
|
193
|
+
conflictsChanged: this.#conflicts,
|
|
194
|
+
rejectionsChanged: this.#rejections,
|
|
93
195
|
};
|
|
94
196
|
}
|
|
95
197
|
}
|
|
96
198
|
|
|
97
|
-
|
|
199
|
+
/** Exception-isolated synchronous change listener set. */
|
|
200
|
+
export class ChangeEmitter {
|
|
201
|
+
readonly #listeners = new Set<ClientChangeListener>();
|
|
202
|
+
|
|
203
|
+
on(listener: ClientChangeListener): () => void {
|
|
204
|
+
this.#listeners.add(listener);
|
|
205
|
+
return () => {
|
|
206
|
+
this.#listeners.delete(listener);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
emit(batch: ClientChangeBatch): void {
|
|
211
|
+
for (const listener of this.#listeners) {
|
|
212
|
+
try {
|
|
213
|
+
listener(batch);
|
|
214
|
+
} catch {
|
|
215
|
+
// Observer code must never corrupt the committed core path.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
get size(): number {
|
|
221
|
+
return this.#listeners.size;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// Compatibility projection
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
98
228
|
|
|
99
229
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* never corrupts the emitting apply path). Reused by the worker handle so
|
|
103
|
-
* both cores expose the identical `onInvalidate` surface.
|
|
230
|
+
* Legacy invalidation shape. It is derived only from an exact core batch;
|
|
231
|
+
* missing bridge information is never treated as global.
|
|
104
232
|
*/
|
|
233
|
+
export interface InvalidationEvent {
|
|
234
|
+
readonly tables: ReadonlySet<string>;
|
|
235
|
+
readonly scopeKeys: ReadonlySet<string>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type InvalidationListener = (event: InvalidationEvent) => void;
|
|
239
|
+
|
|
240
|
+
export function invalidationFromChange(
|
|
241
|
+
batch: ClientChangeBatch,
|
|
242
|
+
): InvalidationEvent | undefined {
|
|
243
|
+
if (batch.tables.length === 0 && batch.windows.length === 0) return undefined;
|
|
244
|
+
const tables = new Set<string>();
|
|
245
|
+
const scopeKeys = new Set<string>();
|
|
246
|
+
for (const change of batch.tables) {
|
|
247
|
+
tables.add(change.table);
|
|
248
|
+
for (const key of change.scopeKeys ?? []) scopeKeys.add(key);
|
|
249
|
+
}
|
|
250
|
+
// Window-only changes project the table for old completeness consumers.
|
|
251
|
+
// The exact API still keeps this distinct, so new table-only reads do not run.
|
|
252
|
+
for (const change of batch.windows) tables.add(change.table);
|
|
253
|
+
return { tables, scopeKeys };
|
|
254
|
+
}
|
|
255
|
+
|
|
105
256
|
export class InvalidationEmitter {
|
|
106
257
|
readonly #listeners = new Set<InvalidationListener>();
|
|
107
258
|
|
|
@@ -117,7 +268,7 @@ export class InvalidationEmitter {
|
|
|
117
268
|
try {
|
|
118
269
|
listener(event);
|
|
119
270
|
} catch {
|
|
120
|
-
//
|
|
271
|
+
// Compatibility observers are isolated like exact observers.
|
|
121
272
|
}
|
|
122
273
|
}
|
|
123
274
|
}
|
|
@@ -126,3 +277,6 @@ export class InvalidationEmitter {
|
|
|
126
277
|
return this.#listeners.size;
|
|
127
278
|
}
|
|
128
279
|
}
|
|
280
|
+
|
|
281
|
+
/** @deprecated Use {@link ChangeAccumulator}. */
|
|
282
|
+
export { ChangeAccumulator as Invalidation };
|