@syncular/client 0.7.0 → 0.9.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 +25 -0
- package/dist/client.d.ts +21 -25
- package/dist/client.js +170 -10
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/invalidation.d.ts +2 -0
- package/dist/invalidation.js +7 -1
- package/dist/outbox.d.ts +5 -0
- package/dist/outcomes.d.ts +81 -0
- package/dist/outcomes.js +131 -0
- package/dist/reactive-store.d.ts +8 -1
- package/dist/reactive-store.js +17 -0
- package/dist/schema.js +12 -0
- package/dist/worker-entry.js +3 -0
- package/dist/worker-host.d.ts +4 -0
- package/dist/worker-host.js +9 -0
- package/dist/worker-protocol.d.ts +4 -0
- package/package.json +3 -3
- package/src/client.ts +265 -43
- package/src/index.ts +1 -0
- package/src/invalidation.ts +9 -1
- package/src/outbox.ts +5 -0
- package/src/outcomes.ts +276 -0
- package/src/reactive-store.ts +28 -0
- package/src/schema.ts +12 -0
- package/src/worker-entry.ts +5 -0
- package/src/worker-host.ts +21 -0
- package/src/worker-protocol.ts +8 -0
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ClientSyncError } from './errors.js';
|
|
2
|
+
import { jsonToRowValue, rowValueToJson } from './schema.js';
|
|
3
|
+
function encodeResults(results) {
|
|
4
|
+
const stored = results.map((result) => {
|
|
5
|
+
if (result.status !== 'conflict')
|
|
6
|
+
return result;
|
|
7
|
+
return {
|
|
8
|
+
status: 'conflict',
|
|
9
|
+
conflict: {
|
|
10
|
+
...result.conflict,
|
|
11
|
+
serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
12
|
+
key,
|
|
13
|
+
rowValueToJson(value),
|
|
14
|
+
])),
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
18
|
+
return JSON.stringify(stored);
|
|
19
|
+
}
|
|
20
|
+
function decodeResults(raw) {
|
|
21
|
+
const stored = JSON.parse(raw);
|
|
22
|
+
return stored.map((result) => {
|
|
23
|
+
if (result.status !== 'conflict')
|
|
24
|
+
return result;
|
|
25
|
+
return {
|
|
26
|
+
status: 'conflict',
|
|
27
|
+
conflict: {
|
|
28
|
+
...result.conflict,
|
|
29
|
+
serverRow: Object.fromEntries(Object.entries(result.conflict.serverRow).map(([key, value]) => [
|
|
30
|
+
key,
|
|
31
|
+
jsonToRowValue(value),
|
|
32
|
+
])),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function parseOutcome(row) {
|
|
38
|
+
return {
|
|
39
|
+
sequence: row.seq,
|
|
40
|
+
clientCommitId: row.client_commit_id,
|
|
41
|
+
status: row.status,
|
|
42
|
+
recordedAtMs: row.recorded_at_ms,
|
|
43
|
+
results: decodeResults(row.results),
|
|
44
|
+
resolution: row.resolution,
|
|
45
|
+
...(typeof row.resolved_at_ms === 'number'
|
|
46
|
+
? { resolvedAtMs: row.resolved_at_ms }
|
|
47
|
+
: {}),
|
|
48
|
+
...(typeof row.replacement_client_commit_id === 'string'
|
|
49
|
+
? { replacementClientCommitId: row.replacement_client_commit_id }
|
|
50
|
+
: {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function recordCommitOutcome(db, outcome) {
|
|
54
|
+
db.exec(`INSERT INTO _syncular_commit_outcomes(
|
|
55
|
+
client_commit_id, status, recorded_at_ms, results, resolution
|
|
56
|
+
) VALUES (?, ?, ?, ?, 'active')`, [
|
|
57
|
+
outcome.clientCommitId,
|
|
58
|
+
outcome.status,
|
|
59
|
+
outcome.recordedAtMs,
|
|
60
|
+
encodeResults(outcome.results),
|
|
61
|
+
]);
|
|
62
|
+
return commitOutcome(db, outcome.clientCommitId);
|
|
63
|
+
}
|
|
64
|
+
export function commitOutcome(db, clientCommitId) {
|
|
65
|
+
const row = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
|
|
66
|
+
resolution, resolved_at_ms, replacement_client_commit_id
|
|
67
|
+
FROM _syncular_commit_outcomes WHERE client_commit_id = ?`, [clientCommitId])[0];
|
|
68
|
+
return row === undefined ? undefined : parseOutcome(row);
|
|
69
|
+
}
|
|
70
|
+
export function listCommitOutcomes(db, query = {}) {
|
|
71
|
+
const limit = query.limit;
|
|
72
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
|
|
73
|
+
throw new ClientSyncError('sync.invalid_request', 'commit outcome limit must be a positive safe integer');
|
|
74
|
+
}
|
|
75
|
+
const where = query.activeOnly
|
|
76
|
+
? "WHERE resolution = 'active' AND status IN ('conflict', 'rejected')"
|
|
77
|
+
: '';
|
|
78
|
+
const rows = db.query(`SELECT seq, client_commit_id, status, recorded_at_ms, results,
|
|
79
|
+
resolution, resolved_at_ms, replacement_client_commit_id
|
|
80
|
+
FROM _syncular_commit_outcomes ${where}
|
|
81
|
+
ORDER BY seq DESC${limit === undefined ? '' : ' LIMIT ?'}`, limit === undefined ? [] : [limit]);
|
|
82
|
+
return rows.map(parseOutcome);
|
|
83
|
+
}
|
|
84
|
+
export function persistCommitOutcomeResolution(db, input, nowMs) {
|
|
85
|
+
db.exec(`UPDATE _syncular_commit_outcomes
|
|
86
|
+
SET resolution = ?, resolved_at_ms = ?, replacement_client_commit_id = ?
|
|
87
|
+
WHERE client_commit_id = ? AND resolution = 'active'`, [
|
|
88
|
+
input.resolution,
|
|
89
|
+
nowMs,
|
|
90
|
+
input.replacementClientCommitId ?? null,
|
|
91
|
+
input.clientCommitId,
|
|
92
|
+
]);
|
|
93
|
+
return commitOutcome(db, input.clientCommitId);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Bound journal growth without deleting active failures. If active failures
|
|
97
|
+
* alone exceed the cap the journal intentionally remains over-capacity.
|
|
98
|
+
*/
|
|
99
|
+
export function pruneCommitOutcomes(db, maxEntries) {
|
|
100
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
101
|
+
throw new ClientSyncError('sync.invalid_request', 'outcome retention maxEntries must be a positive safe integer');
|
|
102
|
+
}
|
|
103
|
+
const count = db.query('SELECT COUNT(*) AS count FROM _syncular_commit_outcomes')[0]?.count;
|
|
104
|
+
const excess = Math.max(0, (count ?? 0) - maxEntries);
|
|
105
|
+
if (excess === 0)
|
|
106
|
+
return 0;
|
|
107
|
+
const candidates = db.query(`SELECT seq FROM _syncular_commit_outcomes
|
|
108
|
+
WHERE status IN ('applied', 'cached') OR resolution != 'active'
|
|
109
|
+
ORDER BY seq ASC LIMIT ?`, [excess]);
|
|
110
|
+
for (const candidate of candidates) {
|
|
111
|
+
db.exec('DELETE FROM _syncular_commit_outcomes WHERE seq = ?', [
|
|
112
|
+
candidate.seq,
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
return candidates.length;
|
|
116
|
+
}
|
|
117
|
+
export function activeFailureRecords(outcomes) {
|
|
118
|
+
const conflicts = [];
|
|
119
|
+
const rejections = [];
|
|
120
|
+
for (const outcome of outcomes) {
|
|
121
|
+
if (outcome.resolution !== 'active')
|
|
122
|
+
continue;
|
|
123
|
+
for (const result of outcome.results) {
|
|
124
|
+
if (result.status === 'conflict')
|
|
125
|
+
conflicts.push(result.conflict);
|
|
126
|
+
if (result.status === 'error')
|
|
127
|
+
rejections.push(result.rejection);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { conflicts, rejections };
|
|
131
|
+
}
|
package/dist/reactive-store.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
1
|
+
import type { CommitOutcome, QueryReadSpec, QuerySnapshot, WindowCoverage, WindowState } from './client.js';
|
|
2
2
|
import type { SqlValue } from './database.js';
|
|
3
3
|
import type { ClientChangeListener, SyncStatusSnapshot } from './invalidation.js';
|
|
4
4
|
import { type WindowBase } from './window.js';
|
|
@@ -29,6 +29,7 @@ export interface ReactiveQueryClient {
|
|
|
29
29
|
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
30
30
|
readonly conflicts: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
31
31
|
readonly rejections: readonly unknown[] | (() => readonly unknown[] | Promise<readonly unknown[]>);
|
|
32
|
+
commitOutcomes(): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
|
|
32
33
|
setWindow(base: WindowBase, units: readonly string[]): void | Promise<void>;
|
|
33
34
|
windowState(base: WindowBase): WindowState | Promise<WindowState>;
|
|
34
35
|
}
|
|
@@ -54,6 +55,11 @@ export interface ConflictStoreSnapshot<Conflict = unknown, Rejection = unknown>
|
|
|
54
55
|
readonly error: Error | undefined;
|
|
55
56
|
readonly isLoading: boolean;
|
|
56
57
|
}
|
|
58
|
+
export interface OutcomeStoreSnapshot {
|
|
59
|
+
readonly outcomes: readonly CommitOutcome[];
|
|
60
|
+
readonly error: Error | undefined;
|
|
61
|
+
readonly isLoading: boolean;
|
|
62
|
+
}
|
|
57
63
|
/** Lossless deterministic identity for query params, bytes, and row keys. */
|
|
58
64
|
export declare function canonicalValue(value: unknown): string;
|
|
59
65
|
export declare class ReactiveClientStore {
|
|
@@ -61,6 +67,7 @@ export declare class ReactiveClientStore {
|
|
|
61
67
|
readonly client: ReactiveQueryClient;
|
|
62
68
|
readonly status: ExternalStoreEntry<StatusStoreSnapshot>;
|
|
63
69
|
readonly conflicts: ExternalStoreEntry<ConflictStoreSnapshot>;
|
|
70
|
+
readonly outcomes: ExternalStoreEntry<OutcomeStoreSnapshot>;
|
|
64
71
|
constructor(client: ReactiveQueryClient);
|
|
65
72
|
query<Row>(spec: ReactiveQuerySpec<Row>): ExternalStoreEntry<LiveQueryResult<Row>>;
|
|
66
73
|
/** Retain a composable window working set outside React. The returned
|
package/dist/reactive-store.js
CHANGED
|
@@ -385,6 +385,7 @@ export class ReactiveClientStore {
|
|
|
385
385
|
#offChange;
|
|
386
386
|
status;
|
|
387
387
|
conflicts;
|
|
388
|
+
outcomes;
|
|
388
389
|
constructor(client) {
|
|
389
390
|
this.client = client;
|
|
390
391
|
const status = new ValueEntry({ status: undefined, error: undefined, isLoading: true }, async () => {
|
|
@@ -421,10 +422,24 @@ export class ReactiveClientStore {
|
|
|
421
422
|
};
|
|
422
423
|
}
|
|
423
424
|
});
|
|
425
|
+
const outcomes = new ValueEntry({ outcomes: [], error: undefined, isLoading: true }, async () => {
|
|
426
|
+
try {
|
|
427
|
+
return {
|
|
428
|
+
outcomes: await client.commitOutcomes(),
|
|
429
|
+
error: undefined,
|
|
430
|
+
isLoading: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
return { outcomes: [], error: errorOf(error), isLoading: false };
|
|
435
|
+
}
|
|
436
|
+
});
|
|
424
437
|
this.status = status;
|
|
425
438
|
this.conflicts = conflicts;
|
|
439
|
+
this.outcomes = outcomes;
|
|
426
440
|
status.refresh();
|
|
427
441
|
conflicts.refresh();
|
|
442
|
+
outcomes.refresh();
|
|
428
443
|
this.start();
|
|
429
444
|
}
|
|
430
445
|
query(spec) {
|
|
@@ -563,6 +578,8 @@ export class ReactiveClientStore {
|
|
|
563
578
|
if (batch.conflictsChanged || batch.rejectionsChanged) {
|
|
564
579
|
this.conflicts.refresh();
|
|
565
580
|
}
|
|
581
|
+
if (batch.outcomesChanged)
|
|
582
|
+
this.outcomes.refresh();
|
|
566
583
|
});
|
|
567
584
|
}
|
|
568
585
|
dispose() {
|
package/dist/schema.js
CHANGED
|
@@ -184,6 +184,18 @@ export function ensureLocalSchema(db, schema) {
|
|
|
184
184
|
client_commit_id TEXT NOT NULL UNIQUE,
|
|
185
185
|
created_at_ms INTEGER NOT NULL,
|
|
186
186
|
operations TEXT NOT NULL)`);
|
|
187
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes(
|
|
188
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
189
|
+
client_commit_id TEXT NOT NULL UNIQUE,
|
|
190
|
+
status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
|
|
191
|
+
recorded_at_ms INTEGER NOT NULL,
|
|
192
|
+
results TEXT NOT NULL,
|
|
193
|
+
resolution TEXT NOT NULL DEFAULT 'active'
|
|
194
|
+
CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
|
|
195
|
+
resolved_at_ms INTEGER,
|
|
196
|
+
replacement_client_commit_id TEXT)`);
|
|
197
|
+
db.exec(`CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
|
|
198
|
+
ON _syncular_commit_outcomes(resolution, seq)`);
|
|
187
199
|
db.exec(`CREATE TABLE IF NOT EXISTS _syncular_subscriptions(
|
|
188
200
|
id TEXT PRIMARY KEY,
|
|
189
201
|
tbl TEXT NOT NULL,
|
package/dist/worker-entry.js
CHANGED
|
@@ -286,6 +286,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
286
286
|
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
287
287
|
conflicts: () => requireClient().conflicts,
|
|
288
288
|
rejections: () => requireClient().rejections,
|
|
289
|
+
commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
|
|
290
|
+
commitOutcomes: (query) => requireClient().commitOutcomes(query),
|
|
291
|
+
resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
|
|
289
292
|
schemaFloor: () => requireClient().schemaFloor,
|
|
290
293
|
leaseState: () => requireClient().leaseState,
|
|
291
294
|
upgrading: () => requireClient().upgrading,
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { ChangeEmitter, type ClientChangeListener, InvalidationEmitter, type Inv
|
|
|
29
29
|
import { type LeaderLease, type LeaderLock } from './leader-lock.js';
|
|
30
30
|
import { type CrossTabChannel, FollowerLink, LeaderBridge } from './multi-tab.js';
|
|
31
31
|
import type { OutboxCommit } from './outbox.js';
|
|
32
|
+
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
32
33
|
import type { ClientSchema } from './schema.js';
|
|
33
34
|
import type { SubscriptionRecord } from './state.js';
|
|
34
35
|
import type { WindowBase } from './window.js';
|
|
@@ -149,6 +150,9 @@ export declare class SyncClientHandle {
|
|
|
149
150
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
150
151
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
151
152
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
153
|
+
commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
|
|
154
|
+
commitOutcomes(query?: CommitOutcomeQuery): Promise<readonly CommitOutcome[]>;
|
|
155
|
+
resolveCommitOutcome(input: ResolveCommitOutcomeInput): Promise<CommitOutcome>;
|
|
152
156
|
schemaFloor(): Promise<SchemaFloor | undefined>;
|
|
153
157
|
leaseState(): Promise<LeaseState | undefined>;
|
|
154
158
|
/** §7.4.5: true while a schema-bump reset + first re-bootstrap runs. */
|
package/dist/worker-host.js
CHANGED
|
@@ -186,6 +186,15 @@ export class SyncClientHandle {
|
|
|
186
186
|
rejections() {
|
|
187
187
|
return this.#call('rejections', []);
|
|
188
188
|
}
|
|
189
|
+
commitOutcome(clientCommitId) {
|
|
190
|
+
return this.#call('commitOutcome', [clientCommitId]);
|
|
191
|
+
}
|
|
192
|
+
commitOutcomes(query = {}) {
|
|
193
|
+
return this.#call('commitOutcomes', [query]);
|
|
194
|
+
}
|
|
195
|
+
resolveCommitOutcome(input) {
|
|
196
|
+
return this.#call('resolveCommitOutcome', [input]);
|
|
197
|
+
}
|
|
189
198
|
schemaFloor() {
|
|
190
199
|
return this.#call('schemaFloor', []);
|
|
191
200
|
}
|
|
@@ -22,6 +22,7 @@ import type { ConflictRecord, LeaseState, MutationInput, PresencePeer, QueryRead
|
|
|
22
22
|
import type { SqlRow, SqlValue } from './database.js';
|
|
23
23
|
import type { ClientChangeBatch, LocalRevision, SyncStatusSnapshot } from './invalidation.js';
|
|
24
24
|
import type { OutboxCommit } from './outbox.js';
|
|
25
|
+
import type { CommitOutcome, CommitOutcomeQuery, ResolveCommitOutcomeInput } from './outcomes.js';
|
|
25
26
|
import type { ClientSchema } from './schema.js';
|
|
26
27
|
import type { SubscriptionRecord } from './state.js';
|
|
27
28
|
import type { WindowBase } from './window.js';
|
|
@@ -93,6 +94,9 @@ export interface WorkerApi {
|
|
|
93
94
|
statusSnapshot(): SyncStatusSnapshot;
|
|
94
95
|
conflicts(): readonly ConflictRecord[];
|
|
95
96
|
rejections(): readonly RejectionRecord[];
|
|
97
|
+
commitOutcome(clientCommitId: string): CommitOutcome | undefined;
|
|
98
|
+
commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[];
|
|
99
|
+
resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome;
|
|
96
100
|
schemaFloor(): SchemaFloor | undefined;
|
|
97
101
|
/** §7.3.5: the opaque auth-lease state, or undefined. */
|
|
98
102
|
leaseState(): LeaseState | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
},
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
84
|
-
"@syncular/core": "0.
|
|
84
|
+
"@syncular/core": "0.9.0"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"better-sqlite3": ">=11"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
95
|
+
"@syncular/server": "0.9.0",
|
|
96
96
|
"@types/better-sqlite3": "^7.6.13",
|
|
97
97
|
"better-sqlite3": "^12.11.1"
|
|
98
98
|
}
|