@syncular/client 0.4.1 → 0.5.1

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/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export * from './multi-tab';
23
23
  export * from './naming';
24
24
  export * from './outbox';
25
25
  export * from './query-guard';
26
+ export * from './reactive-store';
26
27
  export * from './schema';
27
28
  export * from './sql-tag';
28
29
  export * from './state';
@@ -1,107 +1,258 @@
1
1
  /**
2
- * The ONE apply-path invalidation choke point (TODO 3.1 / DESIGN-eviction
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
- * Granularity truth (honest to the wire, §4.5 / §5.2):
12
- * - `COMMIT` changes carry per-row `scopes` (variable value), so their
13
- * `prefix:value` scope keys (§3.1 vocabulary, I2) are emitted precisely.
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
- /** One coalesced invalidation batch (I1). Empty batches are not emitted. */
29
- export interface InvalidationEvent {
30
- /** Tables whose local rows changed this batch — the reliable floor. */
31
- readonly tables: ReadonlySet<string>;
32
- /** `prefix:value` scope keys touched, where the source carried them (I2). */
33
- readonly scopeKeys: ReadonlySet<string>;
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 type InvalidationListener = (event: InvalidationEvent) => void;
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
- * A per-batch accumulator. One is created at the top of each apply batch,
40
- * fed by the apply paths, and flushed once at the batch boundary. Mutable
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 Invalidation {
44
- #tables: Set<string> | undefined;
45
- #scopeKeys: Set<string> | undefined;
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 (the floor for any apply). */
85
+ /** Mark a whole table dirty, discarding any weaker scope-only facts. */
48
86
  table(name: string): void {
49
- if (this.#tables === undefined) this.#tables = new Set();
50
- this.#tables.add(name);
87
+ this.#tables.set(name, { tableWide: true, scopeKeys: undefined });
51
88
  }
52
89
 
53
- /** Add a raw `prefix:value` scope key (§3.1). */
54
- scopeKey(key: string): void {
55
- if (this.#scopeKeys === undefined) this.#scopeKeys = new Set();
56
- this.#scopeKeys.add(key);
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
- * Add the `prefix:value` keys for an effective/requested scope map on
61
- * `table` (variable → list(value)), skipping variables the table has no
62
- * prefix for (never guesses a key).
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 v of values) this.scopeKey(`${prefix}:${v}`);
134
+ for (const value of values) this.scope(table.name, `${prefix}:${value}`);
69
135
  }
70
136
  }
71
137
 
72
- /** A COMMIT change's stored scopes (variable → single value, §4.5). */
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) this.scopeKey(`${prefix}:${value}`);
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 this.#tables !== undefined || this.#scopeKeys !== undefined;
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
- /** Freeze into an event, or undefined when nothing was touched. */
88
- finish(): InvalidationEvent | undefined {
89
- if (!this.touched) return undefined;
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
- tables: this.#tables ?? EMPTY,
92
- scopeKeys: this.#scopeKeys ?? EMPTY,
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
- const EMPTY: ReadonlySet<string> = new Set();
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
- * A tiny subscribable listener set. Notification is synchronous and
101
- * exception-isolated (one throwing listener never starves the rest, and
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
- // A UI listener must never break the apply path (I1).
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 };