@syncular/client 0.4.0 → 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/dist/index.d.ts CHANGED
@@ -23,6 +23,7 @@ export * from './multi-tab.js';
23
23
  export * from './naming.js';
24
24
  export * from './outbox.js';
25
25
  export * from './query-guard.js';
26
+ export * from './reactive-store.js';
26
27
  export * from './schema.js';
27
28
  export * from './sql-tag.js';
28
29
  export * from './state.js';
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ export * from './multi-tab.js';
23
23
  export * from './naming.js';
24
24
  export * from './outbox.js';
25
25
  export * from './query-guard.js';
26
+ export * from './reactive-store.js';
26
27
  export * from './schema.js';
27
28
  export * from './sql-tag.js';
28
29
  export * from './state.js';
@@ -1,69 +1,102 @@
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.js';
26
10
  import type { CompiledClientTable } from './schema.js';
27
- /** One coalesced invalidation batch (I1). Empty batches are not emitted. */
28
- export interface InvalidationEvent {
29
- /** Tables whose local rows changed this batch — the reliable floor. */
30
- readonly tables: ReadonlySet<string>;
31
- /** `prefix:value` scope keys touched, where the source carried them (I2). */
32
- readonly scopeKeys: ReadonlySet<string>;
11
+ export type LocalRevision = bigint;
12
+ export interface TableChange {
13
+ readonly table: string;
14
+ /** Undefined means honestly table-wide; an empty set is never emitted. */
15
+ readonly scopeKeys?: ReadonlySet<string>;
33
16
  }
34
- export type InvalidationListener = (event: InvalidationEvent) => void;
17
+ export interface WindowChange {
18
+ readonly baseKey: string;
19
+ readonly table: string;
20
+ readonly units: ReadonlySet<string>;
21
+ }
22
+ export interface SyncStatusSnapshot {
23
+ readonly outbox: number;
24
+ readonly upgrading: boolean;
25
+ readonly leaseState: LeaseState | undefined;
26
+ readonly schemaFloor: SchemaFloor | undefined;
27
+ readonly syncNeeded: boolean;
28
+ }
29
+ export interface ClientChangeBatch {
30
+ readonly revision: LocalRevision;
31
+ readonly tables: readonly TableChange[];
32
+ readonly windows: readonly WindowChange[];
33
+ readonly status?: SyncStatusSnapshot;
34
+ readonly conflictsChanged: boolean;
35
+ readonly rejectionsChanged: boolean;
36
+ }
37
+ export type ClientChangeListener = (batch: ClientChangeBatch) => void;
38
+ /** Network work created by a core command (SPEC §7.5). */
39
+ export type SyncIntent = {
40
+ readonly kind: 'none';
41
+ } | {
42
+ readonly kind: 'interactive';
43
+ } | {
44
+ readonly kind: 'background';
45
+ readonly delayMs: number;
46
+ };
47
+ export interface CommandEffects {
48
+ readonly sync: SyncIntent;
49
+ }
50
+ export interface CommandResult<T> {
51
+ readonly value: T;
52
+ readonly effects: CommandEffects;
53
+ }
54
+ export declare const NO_COMMAND_EFFECTS: CommandEffects;
35
55
  /**
36
- * A per-batch accumulator. One is created at the top of each apply batch,
37
- * fed by the apply paths, and flushed once at the batch boundary. Mutable
38
- * and cheap on purpose — no allocation until something is actually touched.
56
+ * Per-observer-transaction accumulator. It contains no revision itself: the
57
+ * transaction wrapper assigns the revision only after all writes succeeded.
39
58
  */
40
- export declare class Invalidation {
59
+ export declare class ChangeAccumulator {
41
60
  #private;
42
- /** Mark a whole table dirty (the floor for any apply). */
61
+ /** Mark a whole table dirty, discarding any weaker scope-only facts. */
43
62
  table(name: string): void;
44
- /** Add a raw `prefix:value` scope key (§3.1). */
45
- scopeKey(key: string): void;
46
- /**
47
- * Add the `prefix:value` keys for an effective/requested scope map on
48
- * `table` (variable → list(value)), skipping variables the table has no
49
- * prefix for (never guesses a key).
50
- */
63
+ /** Associate a precise `prefix:value` key with its table. */
64
+ scope(table: string, key: string): void;
65
+ /** Record registration/completeness change for one window unit. */
66
+ window(baseKey: string, table: string, unit: string): void;
67
+ status(): void;
68
+ conflicts(): void;
69
+ rejections(): void;
70
+ /** Add precise keys for a requested/effective scope map. */
51
71
  scopeMap(table: CompiledClientTable, scopes: ScopeMap): void;
52
- /** A COMMIT change's stored scopes (variable → single value, §4.5). */
53
- changeScopes(table: CompiledClientTable, scopes: Record<string, string>): void;
72
+ /** Add precise keys for a COMMIT change's stored scope values. */
73
+ changeScopes(table: CompiledClientTable, scopes: Readonly<Record<string, string>>): void;
54
74
  get touched(): boolean;
55
- /** Freeze into an event, or undefined when nothing was touched. */
56
- finish(): InvalidationEvent | undefined;
75
+ get statusChanged(): boolean;
76
+ finish(revision: LocalRevision, status: SyncStatusSnapshot | undefined): ClientChangeBatch;
77
+ }
78
+ /** Exception-isolated synchronous change listener set. */
79
+ export declare class ChangeEmitter {
80
+ #private;
81
+ on(listener: ClientChangeListener): () => void;
82
+ emit(batch: ClientChangeBatch): void;
83
+ get size(): number;
57
84
  }
58
85
  /**
59
- * A tiny subscribable listener set. Notification is synchronous and
60
- * exception-isolated (one throwing listener never starves the rest, and
61
- * never corrupts the emitting apply path). Reused by the worker handle so
62
- * both cores expose the identical `onInvalidate` surface.
86
+ * Legacy invalidation shape. It is derived only from an exact core batch;
87
+ * missing bridge information is never treated as global.
63
88
  */
89
+ export interface InvalidationEvent {
90
+ readonly tables: ReadonlySet<string>;
91
+ readonly scopeKeys: ReadonlySet<string>;
92
+ }
93
+ export type InvalidationListener = (event: InvalidationEvent) => void;
94
+ export declare function invalidationFromChange(batch: ClientChangeBatch): InvalidationEvent | undefined;
64
95
  export declare class InvalidationEmitter {
65
96
  #private;
66
97
  on(listener: InvalidationListener): () => void;
67
98
  emit(event: InvalidationEvent): void;
68
99
  get size(): number;
69
100
  }
101
+ /** @deprecated Use {@link ChangeAccumulator}. */
102
+ export { ChangeAccumulator as Invalidation };
@@ -1,65 +1,151 @@
1
+ export const NO_COMMAND_EFFECTS = {
2
+ sync: { kind: 'none' },
3
+ };
1
4
  /**
2
- * A per-batch accumulator. One is created at the top of each apply batch,
3
- * fed by the apply paths, and flushed once at the batch boundary. Mutable
4
- * and cheap on purpose — no allocation until something is actually touched.
5
+ * Per-observer-transaction accumulator. It contains no revision itself: the
6
+ * transaction wrapper assigns the revision only after all writes succeeded.
5
7
  */
6
- export class Invalidation {
7
- #tables;
8
- #scopeKeys;
9
- /** Mark a whole table dirty (the floor for any apply). */
8
+ export class ChangeAccumulator {
9
+ #tables = new Map();
10
+ #windows = new Map();
11
+ #status = false;
12
+ #conflicts = false;
13
+ #rejections = false;
14
+ /** Mark a whole table dirty, discarding any weaker scope-only facts. */
10
15
  table(name) {
11
- if (this.#tables === undefined)
12
- this.#tables = new Set();
13
- this.#tables.add(name);
14
- }
15
- /** Add a raw `prefix:value` scope key (§3.1). */
16
- scopeKey(key) {
17
- if (this.#scopeKeys === undefined)
18
- this.#scopeKeys = new Set();
19
- this.#scopeKeys.add(key);
20
- }
21
- /**
22
- * Add the `prefix:value` keys for an effective/requested scope map on
23
- * `table` (variable → list(value)), skipping variables the table has no
24
- * prefix for (never guesses a key).
25
- */
16
+ this.#tables.set(name, { tableWide: true, scopeKeys: undefined });
17
+ }
18
+ /** Associate a precise `prefix:value` key with its table. */
19
+ scope(table, key) {
20
+ const current = this.#tables.get(table);
21
+ if (current?.tableWide)
22
+ return;
23
+ if (current === undefined) {
24
+ this.#tables.set(table, {
25
+ tableWide: false,
26
+ scopeKeys: new Set([key]),
27
+ });
28
+ return;
29
+ }
30
+ current.scopeKeys?.add(key);
31
+ }
32
+ /** Record registration/completeness change for one window unit. */
33
+ window(baseKey, table, unit) {
34
+ const current = this.#windows.get(baseKey);
35
+ if (current === undefined) {
36
+ this.#windows.set(baseKey, { table, units: new Set([unit]) });
37
+ return;
38
+ }
39
+ if (current.table !== table) {
40
+ throw new Error(`window base ${JSON.stringify(baseKey)} changed tables`);
41
+ }
42
+ current.units.add(unit);
43
+ }
44
+ status() {
45
+ this.#status = true;
46
+ }
47
+ conflicts() {
48
+ this.#conflicts = true;
49
+ }
50
+ rejections() {
51
+ this.#rejections = true;
52
+ }
53
+ /** Add precise keys for a requested/effective scope map. */
26
54
  scopeMap(table, scopes) {
27
55
  for (const [variable, values] of Object.entries(scopes)) {
28
56
  const prefix = table.scopePrefixByVariable.get(variable);
29
57
  if (prefix === undefined)
30
58
  continue;
31
- for (const v of values)
32
- this.scopeKey(`${prefix}:${v}`);
59
+ for (const value of values)
60
+ this.scope(table.name, `${prefix}:${value}`);
33
61
  }
34
62
  }
35
- /** A COMMIT change's stored scopes (variable → single value, §4.5). */
63
+ /** Add precise keys for a COMMIT change's stored scope values. */
36
64
  changeScopes(table, scopes) {
37
65
  for (const [variable, value] of Object.entries(scopes)) {
38
66
  const prefix = table.scopePrefixByVariable.get(variable);
39
- if (prefix !== undefined)
40
- this.scopeKey(`${prefix}:${value}`);
67
+ if (prefix !== undefined) {
68
+ this.scope(table.name, `${prefix}:${value}`);
69
+ }
41
70
  }
42
71
  }
43
72
  get touched() {
44
- return this.#tables !== undefined || this.#scopeKeys !== undefined;
73
+ return (this.#tables.size > 0 ||
74
+ this.#windows.size > 0 ||
75
+ this.#status ||
76
+ this.#conflicts ||
77
+ this.#rejections);
78
+ }
79
+ get statusChanged() {
80
+ return this.#status;
45
81
  }
46
- /** Freeze into an event, or undefined when nothing was touched. */
47
- finish() {
48
- if (!this.touched)
49
- return undefined;
82
+ finish(revision, status) {
83
+ if (this.#status && status === undefined) {
84
+ throw new Error('status change batch requires a post-commit snapshot');
85
+ }
86
+ const tables = [];
87
+ for (const [table, change] of this.#tables) {
88
+ tables.push(change.tableWide
89
+ ? { table }
90
+ : { table, scopeKeys: change.scopeKeys });
91
+ }
92
+ const windows = [];
93
+ for (const [baseKey, change] of this.#windows) {
94
+ windows.push({
95
+ baseKey,
96
+ table: change.table,
97
+ units: change.units,
98
+ });
99
+ }
50
100
  return {
51
- tables: this.#tables ?? EMPTY,
52
- scopeKeys: this.#scopeKeys ?? EMPTY,
101
+ revision,
102
+ tables,
103
+ windows,
104
+ ...(this.#status ? { status: status } : {}),
105
+ conflictsChanged: this.#conflicts,
106
+ rejectionsChanged: this.#rejections,
53
107
  };
54
108
  }
55
109
  }
56
- const EMPTY = new Set();
57
- /**
58
- * A tiny subscribable listener set. Notification is synchronous and
59
- * exception-isolated (one throwing listener never starves the rest, and
60
- * never corrupts the emitting apply path). Reused by the worker handle so
61
- * both cores expose the identical `onInvalidate` surface.
62
- */
110
+ /** Exception-isolated synchronous change listener set. */
111
+ export class ChangeEmitter {
112
+ #listeners = new Set();
113
+ on(listener) {
114
+ this.#listeners.add(listener);
115
+ return () => {
116
+ this.#listeners.delete(listener);
117
+ };
118
+ }
119
+ emit(batch) {
120
+ for (const listener of this.#listeners) {
121
+ try {
122
+ listener(batch);
123
+ }
124
+ catch {
125
+ // Observer code must never corrupt the committed core path.
126
+ }
127
+ }
128
+ }
129
+ get size() {
130
+ return this.#listeners.size;
131
+ }
132
+ }
133
+ export function invalidationFromChange(batch) {
134
+ if (batch.tables.length === 0 && batch.windows.length === 0)
135
+ return undefined;
136
+ const tables = new Set();
137
+ const scopeKeys = new Set();
138
+ for (const change of batch.tables) {
139
+ tables.add(change.table);
140
+ for (const key of change.scopeKeys ?? [])
141
+ scopeKeys.add(key);
142
+ }
143
+ // Window-only changes project the table for old completeness consumers.
144
+ // The exact API still keeps this distinct, so new table-only reads do not run.
145
+ for (const change of batch.windows)
146
+ tables.add(change.table);
147
+ return { tables, scopeKeys };
148
+ }
63
149
  export class InvalidationEmitter {
64
150
  #listeners = new Set();
65
151
  on(listener) {
@@ -74,7 +160,7 @@ export class InvalidationEmitter {
74
160
  listener(event);
75
161
  }
76
162
  catch {
77
- // A UI listener must never break the apply path (I1).
163
+ // Compatibility observers are isolated like exact observers.
78
164
  }
79
165
  }
80
166
  }
@@ -82,3 +168,5 @@ export class InvalidationEmitter {
82
168
  return this.#listeners.size;
83
169
  }
84
170
  }
171
+ /** @deprecated Use {@link ChangeAccumulator}. */
172
+ export { ChangeAccumulator as Invalidation };
@@ -0,0 +1,74 @@
1
+ import type { QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
2
+ import type { SqlValue } from './database.js';
3
+ import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
4
+ import { type WindowBase } from './window.js';
5
+ export interface QueryDependency {
6
+ readonly table: string;
7
+ readonly scopeKeys?: readonly string[];
8
+ }
9
+ export interface ReactiveQuerySpec<Row> {
10
+ readonly id: string;
11
+ readonly sql: string;
12
+ readonly params?: readonly SqlValue[];
13
+ readonly dependencies: readonly QueryDependency[];
14
+ readonly coverage?: readonly WindowCoverage[];
15
+ readonly rowKey?: (row: Row) => readonly SqlValue[];
16
+ readonly claimCoverage?: boolean;
17
+ }
18
+ export type LiveQueryPhase = 'loading' | 'partial' | 'ready' | 'error';
19
+ export interface LiveQueryResult<Row> {
20
+ readonly rows: readonly Row[];
21
+ readonly phase: LiveQueryPhase;
22
+ readonly revision: bigint | undefined;
23
+ readonly error: Error | undefined;
24
+ readonly isRefreshing: boolean;
25
+ }
26
+ export interface ReactiveQueryClient {
27
+ onChange(listener: ClientChangeListener): () => void;
28
+ querySnapshot<Row = Record<string, SqlValue>>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
29
+ statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
30
+ readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
31
+ readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
32
+ setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
33
+ windowState(base: WindowBase): WindowState | Promise<WindowState>;
34
+ }
35
+ export interface ExternalStoreEntry<T> {
36
+ subscribe(listener: () => void): () => void;
37
+ getSnapshot(): T;
38
+ refresh(): void;
39
+ }
40
+ export interface WindowRetention {
41
+ /** Resolves once this retained claim has reached the core window. */
42
+ readonly ready: Promise<void>;
43
+ /** Release only this retention owner's units from the composable union. */
44
+ readonly release: () => void;
45
+ }
46
+ export interface StatusStoreSnapshot {
47
+ readonly status: SyncStatusSnapshot | undefined;
48
+ readonly error: Error | undefined;
49
+ readonly isLoading: boolean;
50
+ }
51
+ export interface ConflictStoreSnapshot<Conflict = unknown, Rejection = unknown> {
52
+ readonly conflicts: readonly Conflict[];
53
+ readonly rejections: readonly Rejection[];
54
+ readonly error: Error | undefined;
55
+ readonly isLoading: boolean;
56
+ }
57
+ /** Lossless deterministic identity for query params, bytes, and row keys. */
58
+ export declare function canonicalValue(value: unknown): string;
59
+ export declare class ReactiveClientStore {
60
+ #private;
61
+ readonly client: ReactiveQueryClient;
62
+ readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
63
+ readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
64
+ constructor(client: ReactiveQueryClient);
65
+ query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
66
+ /** Retain a composable window working set outside React. The returned
67
+ * handle exposes registration completion and releases only this owner. */
68
+ retainWindow(base: WindowBase, units: readonly string[]): WindowRetention;
69
+ window(base: WindowBase): ExternalStoreEntry<WindowState>;
70
+ setWindowClaim(owner: symbol, base: WindowBase, units: readonly string[]): Promise<void>;
71
+ releaseWindowClaims(owner: symbol): void;
72
+ start(): void;
73
+ dispose(): void;
74
+ }