@syncular/client 0.30.15 → 0.31.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 +37 -1
- package/dist/client.d.ts +102 -2
- package/dist/client.js +668 -146
- package/dist/diagnostics.d.ts +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +8 -3
- package/dist/failed-overlay.js +12 -8
- package/dist/http.d.ts +3 -3
- package/dist/http.js +88 -61
- package/dist/index.d.ts +1 -1
- package/dist/index.js +0 -11
- package/dist/outcomes.d.ts +9 -1
- package/dist/outcomes.js +186 -34
- package/dist/schema.d.ts +8 -0
- package/dist/schema.js +31 -1
- package/dist/state.js +5 -4
- package/dist/sync-scheduler.js +8 -0
- package/dist/worker-entry.d.ts +18 -0
- package/dist/worker-entry.js +45 -46
- package/dist/worker-host.d.ts +3 -2
- package/dist/worker-host.js +3 -0
- package/dist/worker-protocol.d.ts +2 -2
- package/package.json +11 -3
- package/src/client.ts +936 -185
- package/src/diagnostics.ts +2 -0
- package/src/errors.ts +10 -4
- package/src/failed-overlay.ts +10 -15
- package/src/http.ts +198 -96
- package/src/index.ts +4 -5
- package/src/outcomes.ts +230 -46
- package/src/schema.ts +42 -1
- package/src/state.ts +14 -4
- package/src/sync-scheduler.ts +10 -0
- package/src/worker-entry.ts +54 -38
- package/src/worker-host.ts +10 -1
- package/src/worker-protocol.ts +2 -1
package/dist/outcomes.js
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
export const OUTCOMES_TABLE = '_syncular_commit_outcomes';
|
|
2
2
|
const SELECT_OUTCOME = `SELECT * FROM ${OUTCOMES_TABLE}`;
|
|
3
3
|
import { RETAINED_ROWS } from './failed-overlay.js';
|
|
4
|
+
/**
|
|
5
|
+
* Durable per-client commit outcomes.
|
|
6
|
+
*
|
|
7
|
+
* The journal is client-local protected database state. A final push result is
|
|
8
|
+
* written in the same SQLite transaction that drains its outbox commit, so a
|
|
9
|
+
* restart can never turn "rejected" into an inferred success. Conflict payloads
|
|
10
|
+
* deliberately stay local; retention never deletes an unresolved failure.
|
|
11
|
+
*/
|
|
12
|
+
import { normalizeRejectionDetails, } from '@syncular/core';
|
|
4
13
|
import { ClientSyncError } from './errors.js';
|
|
5
|
-
import { mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
14
|
+
import { isBytesEnvelope, mapRowValues, jsonToRowValue, rowValueToJson, } from './schema.js';
|
|
6
15
|
function encodeResults(results) {
|
|
7
16
|
const stored = results.map((result) => {
|
|
8
17
|
if (result.status !== 'conflict')
|
|
@@ -17,45 +26,132 @@ function encodeResults(results) {
|
|
|
17
26
|
});
|
|
18
27
|
return JSON.stringify(stored);
|
|
19
28
|
}
|
|
29
|
+
/** A JSON object that is neither null nor an array. */
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
function isStoredRow(value) {
|
|
34
|
+
return (isRecord(value) &&
|
|
35
|
+
Object.values(value).every((cell) => cell === null ||
|
|
36
|
+
typeof cell === 'string' ||
|
|
37
|
+
typeof cell === 'boolean' ||
|
|
38
|
+
(typeof cell === 'number' && Number.isFinite(cell)) ||
|
|
39
|
+
isBytesEnvelope(cell)));
|
|
40
|
+
}
|
|
41
|
+
export function isStoredOperation(value) {
|
|
42
|
+
return (isRecord(value) &&
|
|
43
|
+
typeof value.table === 'string' &&
|
|
44
|
+
typeof value.rowId === 'string' &&
|
|
45
|
+
(value.baseVersion === undefined ||
|
|
46
|
+
Number.isSafeInteger(value.baseVersion)) &&
|
|
47
|
+
((value.op === 'upsert' && isStoredRow(value.values)) ||
|
|
48
|
+
(value.op === 'delete' && value.values === undefined)));
|
|
49
|
+
}
|
|
20
50
|
function decodeResults(raw) {
|
|
51
|
+
function corrupt() {
|
|
52
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
|
|
53
|
+
}
|
|
21
54
|
const stored = JSON.parse(raw);
|
|
55
|
+
if (!Array.isArray(stored))
|
|
56
|
+
corrupt();
|
|
22
57
|
return stored.map((result) => {
|
|
23
|
-
if (result
|
|
58
|
+
if (!isRecord(result))
|
|
59
|
+
corrupt();
|
|
60
|
+
if (result.status === 'applied') {
|
|
61
|
+
if (!Number.isInteger(result.opIndex) ||
|
|
62
|
+
result.opIndex < -2147483648 ||
|
|
63
|
+
result.opIndex > 2147483647)
|
|
64
|
+
corrupt();
|
|
24
65
|
return result;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
conflict
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
66
|
+
}
|
|
67
|
+
if (result.status === 'conflict') {
|
|
68
|
+
const conflict = result.conflict;
|
|
69
|
+
if (!isRecord(conflict) ||
|
|
70
|
+
!isStoredRow(conflict.serverRow) ||
|
|
71
|
+
typeof conflict.clientCommitId !== 'string' ||
|
|
72
|
+
!Number.isInteger(conflict.opIndex) ||
|
|
73
|
+
conflict.opIndex < -2147483648 ||
|
|
74
|
+
conflict.opIndex > 2147483647 ||
|
|
75
|
+
typeof conflict.table !== 'string' ||
|
|
76
|
+
typeof conflict.rowId !== 'string' ||
|
|
77
|
+
typeof conflict.code !== 'string' ||
|
|
78
|
+
typeof conflict.message !== 'string' ||
|
|
79
|
+
(conflict.operation !== undefined &&
|
|
80
|
+
!isStoredOperation(conflict.operation)) ||
|
|
81
|
+
!Number.isSafeInteger(conflict.serverVersion) ||
|
|
82
|
+
(conflict.conflictColumns !== undefined &&
|
|
83
|
+
(!Array.isArray(conflict.conflictColumns) ||
|
|
84
|
+
!conflict.conflictColumns.every((column) => typeof column === 'string')))) {
|
|
85
|
+
corrupt();
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
status: 'conflict',
|
|
89
|
+
conflict: {
|
|
90
|
+
...conflict,
|
|
91
|
+
// Pre-column-version journal entries carry no conflictColumns.
|
|
92
|
+
conflictColumns: conflict.conflictColumns ?? [],
|
|
93
|
+
serverRow: mapRowValues(conflict.serverRow, jsonToRowValue),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (result.status === 'error') {
|
|
98
|
+
const rejection = result.rejection;
|
|
99
|
+
if (!isRecord(rejection) ||
|
|
100
|
+
typeof rejection.clientCommitId !== 'string' ||
|
|
101
|
+
!Number.isInteger(rejection.opIndex) ||
|
|
102
|
+
rejection.opIndex < -2147483648 ||
|
|
103
|
+
rejection.opIndex > 2147483647 ||
|
|
104
|
+
typeof rejection.code !== 'string' ||
|
|
105
|
+
typeof rejection.message !== 'string' ||
|
|
106
|
+
typeof rejection.retryable !== 'boolean' ||
|
|
107
|
+
(rejection.operation !== undefined &&
|
|
108
|
+
!isStoredOperation(rejection.operation))) {
|
|
109
|
+
corrupt();
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const { details, ...fields } = rejection;
|
|
113
|
+
return {
|
|
114
|
+
status: 'error',
|
|
115
|
+
rejection: {
|
|
116
|
+
...fields,
|
|
117
|
+
...(details == null
|
|
118
|
+
? {}
|
|
119
|
+
: { details: normalizeRejectionDetails(details) }),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
corrupt();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
corrupt();
|
|
34
128
|
});
|
|
35
129
|
}
|
|
36
|
-
function parseOutcome(db, row) {
|
|
37
|
-
const retainedRows =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
130
|
+
function parseOutcome(db, row, attachRetainedRows = true) {
|
|
131
|
+
const retainedRows = attachRetainedRows
|
|
132
|
+
? db
|
|
133
|
+
.query(`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`, [row.client_commit_id])
|
|
134
|
+
.map((retained) => {
|
|
135
|
+
const decode = (value) => value === null
|
|
136
|
+
? null
|
|
137
|
+
: Object.fromEntries(Object.entries(JSON.parse(String(value))).map(([key, value]) => [key, jsonToRowValue(value)]));
|
|
138
|
+
return {
|
|
139
|
+
table: retained.tbl,
|
|
140
|
+
rowId: retained.id,
|
|
141
|
+
localRow: decode(retained.intent),
|
|
142
|
+
...(retained.unique_conflicts === null
|
|
143
|
+
? {}
|
|
144
|
+
: {
|
|
145
|
+
uniqueConflicts: JSON.parse(retained.unique_conflicts).map((conflict) => ({
|
|
146
|
+
...conflict,
|
|
147
|
+
serverRow: decode(JSON.stringify(conflict.serverRow)),
|
|
148
|
+
})),
|
|
149
|
+
}),
|
|
150
|
+
serverRow: decode(retained.base),
|
|
151
|
+
serverVersion: retained.version === null ? null : retained.version,
|
|
152
|
+
};
|
|
153
|
+
})
|
|
154
|
+
: [];
|
|
59
155
|
return {
|
|
60
156
|
sequence: row.seq,
|
|
61
157
|
clientCommitId: row.client_commit_id,
|
|
@@ -93,6 +189,62 @@ export function commitOutcome(db, clientCommitId) {
|
|
|
93
189
|
])[0];
|
|
94
190
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
95
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
|
|
194
|
+
* `retainedRows` images. A malformed journal row (invalid status, resolution,
|
|
195
|
+
* results, or operations JSON) raises the same static `sync.local_corrupt` the
|
|
196
|
+
* native sidecar reports, never a raw `SyntaxError`.
|
|
197
|
+
*/
|
|
198
|
+
export function persistedCommitOutcome(db, clientCommitId) {
|
|
199
|
+
const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
|
|
200
|
+
clientCommitId,
|
|
201
|
+
])[0];
|
|
202
|
+
if (row === undefined)
|
|
203
|
+
return undefined;
|
|
204
|
+
function corrupt() {
|
|
205
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted commit outcome is invalid');
|
|
206
|
+
}
|
|
207
|
+
const status = row.status;
|
|
208
|
+
const resolution = row.resolution;
|
|
209
|
+
if ((status !== 'applied' &&
|
|
210
|
+
status !== 'cached' &&
|
|
211
|
+
status !== 'conflict' &&
|
|
212
|
+
status !== 'rejected') ||
|
|
213
|
+
(resolution !== 'active' &&
|
|
214
|
+
resolution !== 'resolved_keep_server' &&
|
|
215
|
+
resolution !== 'superseded' &&
|
|
216
|
+
resolution !== 'dismissed') ||
|
|
217
|
+
!Number.isSafeInteger(row.seq) ||
|
|
218
|
+
typeof row.client_commit_id !== 'string' ||
|
|
219
|
+
!Number.isSafeInteger(row.recorded_at_ms) ||
|
|
220
|
+
typeof row.results !== 'string' ||
|
|
221
|
+
(row.operations !== null && typeof row.operations !== 'string') ||
|
|
222
|
+
(row.resolved_at_ms !== null &&
|
|
223
|
+
!Number.isSafeInteger(row.resolved_at_ms)) ||
|
|
224
|
+
(row.replacement_client_commit_id !== null &&
|
|
225
|
+
typeof row.replacement_client_commit_id !== 'string')) {
|
|
226
|
+
corrupt();
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const outcome = parseOutcome(db, row, false);
|
|
230
|
+
const hasConflict = outcome.results.some((result) => result.status === 'conflict');
|
|
231
|
+
const hasError = outcome.results.some((result) => result.status === 'error');
|
|
232
|
+
const redacted = outcome.results.length === 0 && outcome.operations === undefined;
|
|
233
|
+
if (((status === 'applied' || status === 'cached') &&
|
|
234
|
+
(hasConflict || hasError)) ||
|
|
235
|
+
(status === 'conflict' && !(hasConflict || redacted)) ||
|
|
236
|
+
(status === 'rejected' && (hasConflict || !(hasError || redacted))))
|
|
237
|
+
corrupt();
|
|
238
|
+
if (outcome.operations !== undefined &&
|
|
239
|
+
(!Array.isArray(outcome.operations) ||
|
|
240
|
+
!outcome.operations.every(isStoredOperation)))
|
|
241
|
+
corrupt();
|
|
242
|
+
return outcome;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
corrupt();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
96
248
|
export function listCommitOutcomes(db, query = {}) {
|
|
97
249
|
const limit = query.limit;
|
|
98
250
|
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1)) {
|
package/dist/schema.d.ts
CHANGED
|
@@ -159,6 +159,14 @@ export declare function recordToRowValues(table: CompiledClientTable, record: Re
|
|
|
159
159
|
export type JsonRowValue = string | number | boolean | null | {
|
|
160
160
|
readonly $bytes: string;
|
|
161
161
|
};
|
|
162
|
+
/**
|
|
163
|
+
* The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
|
|
164
|
+
* of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
|
|
165
|
+
* lowercase. A malformed envelope is a caller error, never a value to coerce.
|
|
166
|
+
*/
|
|
167
|
+
export declare function isBytesEnvelope(value: unknown): value is {
|
|
168
|
+
readonly $bytes: string;
|
|
169
|
+
};
|
|
162
170
|
export declare function hexToBytes(hex: string): Uint8Array;
|
|
163
171
|
export declare function rowValueToJson(value: RowValue): JsonRowValue;
|
|
164
172
|
export declare function jsonToRowValue(value: JsonRowValue): RowValue;
|
package/dist/schema.js
CHANGED
|
@@ -20,6 +20,15 @@ function invalidSchema(message) {
|
|
|
20
20
|
throw new Error(message);
|
|
21
21
|
}
|
|
22
22
|
export function compileClientSchema(schema) {
|
|
23
|
+
// §7.4.2: the persisted marker is a canonical positive i32 and a replica
|
|
24
|
+
// refuses any requested version outside that range before it touches
|
|
25
|
+
// storage, so a client can never persist a marker it would reject on the
|
|
26
|
+
// next open.
|
|
27
|
+
if (!Number.isInteger(schema.version) ||
|
|
28
|
+
schema.version < 1 ||
|
|
29
|
+
schema.version > 2147483647) {
|
|
30
|
+
throw invalidRequest('generated schema version must be an integer in 1..=2147483647');
|
|
31
|
+
}
|
|
23
32
|
const tables = new Map();
|
|
24
33
|
const schemaObjectNames = new Set();
|
|
25
34
|
for (const table of schema.tables) {
|
|
@@ -496,7 +505,13 @@ export function coerceSqlRepresentation(column, value) {
|
|
|
496
505
|
switch (localColumnType(column)) {
|
|
497
506
|
case 'boolean':
|
|
498
507
|
return value === 0 ? false : value === 1 ? true : value;
|
|
499
|
-
case 'integer':
|
|
508
|
+
case 'integer': {
|
|
509
|
+
const integer = typeof value === 'bigint' ? Number(value) : value;
|
|
510
|
+
if (typeof integer === 'number' && !Number.isSafeInteger(integer)) {
|
|
511
|
+
throw invalidRequest('integer value is outside the safe-integer range');
|
|
512
|
+
}
|
|
513
|
+
return integer;
|
|
514
|
+
}
|
|
500
515
|
case 'float':
|
|
501
516
|
return typeof value === 'bigint' ? Number(value) : value;
|
|
502
517
|
default:
|
|
@@ -527,6 +542,19 @@ function bytesToHex(bytes) {
|
|
|
527
542
|
out += byte.toString(16).padStart(2, '0');
|
|
528
543
|
return out;
|
|
529
544
|
}
|
|
545
|
+
/**
|
|
546
|
+
* The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
|
|
547
|
+
* of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
|
|
548
|
+
* lowercase. A malformed envelope is a caller error, never a value to coerce.
|
|
549
|
+
*/
|
|
550
|
+
export function isBytesEnvelope(value) {
|
|
551
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
552
|
+
return false;
|
|
553
|
+
const record = value;
|
|
554
|
+
return (Object.keys(record).length === 1 &&
|
|
555
|
+
typeof record.$bytes === 'string' &&
|
|
556
|
+
/^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes));
|
|
557
|
+
}
|
|
530
558
|
export function hexToBytes(hex) {
|
|
531
559
|
const out = new Uint8Array(hex.length / 2);
|
|
532
560
|
for (let i = 0; i < out.length; i++) {
|
|
@@ -541,6 +569,8 @@ export function rowValueToJson(value) {
|
|
|
541
569
|
}
|
|
542
570
|
export function jsonToRowValue(value) {
|
|
543
571
|
if (typeof value === 'object' && value !== null) {
|
|
572
|
+
if (!isBytesEnvelope(value))
|
|
573
|
+
throw invalidRequest('an object value must be one $bytes key holding an even-length hexadecimal string');
|
|
544
574
|
return hexToBytes(value.$bytes);
|
|
545
575
|
}
|
|
546
576
|
return value;
|
package/dist/state.js
CHANGED
|
@@ -2,6 +2,7 @@ export const META_TABLE = '_syncular_meta';
|
|
|
2
2
|
import { PENDING_EVICTIONS } from './window.js';
|
|
3
3
|
import { WINDOWS_TABLE } from './window.js';
|
|
4
4
|
export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
|
|
5
|
+
import { ClientSyncError } from './errors.js';
|
|
5
6
|
export const LOCAL_REVISION_KEY = 'localRevision';
|
|
6
7
|
const MAX_U64 = 18446744073709551615n;
|
|
7
8
|
function rowToRecord(row) {
|
|
@@ -145,12 +146,12 @@ export function getLocalRevision(db) {
|
|
|
145
146
|
const raw = getMeta(db, LOCAL_REVISION_KEY);
|
|
146
147
|
if (raw === undefined)
|
|
147
148
|
return 0n;
|
|
148
|
-
if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
149
|
-
throw new
|
|
149
|
+
if (typeof raw !== 'string' || !/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
150
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
|
|
150
151
|
}
|
|
151
152
|
const revision = BigInt(raw);
|
|
152
153
|
if (revision > MAX_U64) {
|
|
153
|
-
throw new
|
|
154
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted local revision is invalid');
|
|
154
155
|
}
|
|
155
156
|
return revision;
|
|
156
157
|
}
|
|
@@ -161,7 +162,7 @@ export function getLocalRevision(db) {
|
|
|
161
162
|
export function bumpLocalRevision(db) {
|
|
162
163
|
const current = getLocalRevision(db);
|
|
163
164
|
if (current === MAX_U64) {
|
|
164
|
-
throw new
|
|
165
|
+
throw new ClientSyncError('sync.local_corrupt', 'local revision exhausted u64');
|
|
165
166
|
}
|
|
166
167
|
const next = current + 1n;
|
|
167
168
|
setMeta(db, LOCAL_REVISION_KEY, next.toString());
|
package/dist/sync-scheduler.js
CHANGED
|
@@ -55,6 +55,14 @@ export function installSyncScheduler(client, options = {}) {
|
|
|
55
55
|
backgroundReady = false;
|
|
56
56
|
void client
|
|
57
57
|
.syncUntilIdle(options.maxRounds)
|
|
58
|
+
.then((result) => {
|
|
59
|
+
if (result !== null &&
|
|
60
|
+
typeof result === 'object' &&
|
|
61
|
+
'budgetExhausted' in result &&
|
|
62
|
+
result.budgetExhausted === true) {
|
|
63
|
+
queueImmediate();
|
|
64
|
+
}
|
|
65
|
+
})
|
|
58
66
|
.catch((error) => {
|
|
59
67
|
if (!stopped)
|
|
60
68
|
report(error);
|
package/dist/worker-entry.d.ts
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side bootstrap: constructs the WHOLE
|
|
3
|
+
* client core — SyncClient + transports + realtime + SQLite — inside the
|
|
4
|
+
* worker and serves the `worker-protocol` RPC over postMessage.
|
|
5
|
+
*
|
|
6
|
+
* Browsers get the defaults: opfs-sahpool persistence and the fetch/WS
|
|
7
|
+
* transports from `./http`, wired from `WorkerInitConfig.endpoints`.
|
|
8
|
+
* Tests inject a database factory (bun:sqlite) through `overrides` — the
|
|
9
|
+
* same bootstrap, the same RPC, a different SQLite.
|
|
10
|
+
*
|
|
11
|
+
* The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
|
|
12
|
+
* drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
|
|
13
|
+
* RPC-driven and auto-driven sync rounds serialize on one queue because
|
|
14
|
+
* the core owns exactly one loop.
|
|
15
|
+
*/
|
|
16
|
+
import type { defineAuthorityReads } from './authority.js';
|
|
1
17
|
import type { ClientDatabase } from './database.js';
|
|
2
18
|
import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
|
|
3
19
|
import { type WorkerInitConfig } from './worker-protocol.js';
|
|
4
20
|
export interface SyncWorkerOverrides {
|
|
21
|
+
/** Explicit authority reader factory. Required when init declares authorityReads. */
|
|
22
|
+
readonly createAuthorityReads?: typeof defineAuthorityReads;
|
|
5
23
|
/** Database factory indirection: tests inject bun:sqlite here. */
|
|
6
24
|
readonly openDatabase?: (config: WorkerInitConfig) => Promise<ClientDatabase> | ClientDatabase;
|
|
7
25
|
/** Retry deadline indirection for deterministic worker-host tests. */
|
package/dist/worker-entry.js
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Worker-side bootstrap: constructs the WHOLE
|
|
3
|
-
* client core — SyncClient + transports + realtime + SQLite — inside the
|
|
4
|
-
* worker and serves the `worker-protocol` RPC over postMessage.
|
|
5
|
-
*
|
|
6
|
-
* Browsers get the defaults: opfs-sahpool persistence and the fetch/WS
|
|
7
|
-
* transports from `./http`, wired from `WorkerInitConfig.endpoints`.
|
|
8
|
-
* Tests inject a database factory (bun:sqlite) through `overrides` — the
|
|
9
|
-
* same bootstrap, the same RPC, a different SQLite.
|
|
10
|
-
*
|
|
11
|
-
* The §8.4 host loop lives HERE: wake-ups (hello/`sync` events, delta
|
|
12
|
-
* drops) coalesce into one intent-driven `syncUntilIdle` round in the worker;
|
|
13
|
-
* RPC-driven and auto-driven sync rounds serialize on one queue because
|
|
14
|
-
* the core owns exactly one loop.
|
|
15
|
-
*/
|
|
16
|
-
import { defineAuthorityReads } from './authority.js';
|
|
17
1
|
import { SyncClient } from './client.js';
|
|
18
2
|
import { withClientDiagnosticsHost, } from './diagnostics.js';
|
|
19
3
|
import { encryptionConfigFromKeyring } from './encryption.js';
|
|
@@ -104,6 +88,8 @@ export function startSyncWorker(overrides = {}) {
|
|
|
104
88
|
.then((summary) => {
|
|
105
89
|
if (!closed)
|
|
106
90
|
post({ t: 'event', event: { kind: 'synced', summary } });
|
|
91
|
+
if (summary.budgetExhausted)
|
|
92
|
+
consumeSyncIntent({ kind: 'interactive' });
|
|
107
93
|
})
|
|
108
94
|
.catch((error) => {
|
|
109
95
|
if (!closed) {
|
|
@@ -160,6 +146,8 @@ export function startSyncWorker(overrides = {}) {
|
|
|
160
146
|
return client;
|
|
161
147
|
}
|
|
162
148
|
async function init(config) {
|
|
149
|
+
if (config.authorityReads !== undefined && !overrides.createAuthorityReads)
|
|
150
|
+
throw new ClientSyncError('client.authority_read_forbidden', 'authorityReads requires startSyncWorker({ createAuthorityReads })');
|
|
163
151
|
if (client !== undefined) {
|
|
164
152
|
throw new ClientSyncError(WORKER_FAILED_CODE, 'the worker is already initialized');
|
|
165
153
|
}
|
|
@@ -235,7 +223,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
235
223
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
236
224
|
: {}),
|
|
237
225
|
...(config.authorityReads !== undefined
|
|
238
|
-
? {
|
|
226
|
+
? {
|
|
227
|
+
authorityReads: overrides.createAuthorityReads(config.authorityReads),
|
|
228
|
+
}
|
|
239
229
|
: {}),
|
|
240
230
|
...(config.securityPreflight !== undefined
|
|
241
231
|
? { securityPreflight: config.securityPreflight }
|
|
@@ -300,8 +290,45 @@ export function startSyncWorker(overrides = {}) {
|
|
|
300
290
|
consumeSyncIntent({ kind: 'interactive' });
|
|
301
291
|
return { clientId: started.clientId };
|
|
302
292
|
}
|
|
293
|
+
// Forward every argument to core methods, including authority's zero-argument check.
|
|
294
|
+
const coreMethods = [
|
|
295
|
+
'securityLifecycle',
|
|
296
|
+
'subscribe',
|
|
297
|
+
'unsubscribe',
|
|
298
|
+
'windowState',
|
|
299
|
+
'purgeLocalData',
|
|
300
|
+
'rebootstrapLocalData',
|
|
301
|
+
'previousVersionSnapshot',
|
|
302
|
+
'previousVersionAudit',
|
|
303
|
+
'previousVersionDiscard',
|
|
304
|
+
'query',
|
|
305
|
+
'authoritySnapshot',
|
|
306
|
+
'querySnapshot',
|
|
307
|
+
'snapshotRead',
|
|
308
|
+
'statusSnapshot',
|
|
309
|
+
'conflicts',
|
|
310
|
+
'rejections',
|
|
311
|
+
'commitOutcome',
|
|
312
|
+
'commitOutcomes',
|
|
313
|
+
'resolveCommitOutcome',
|
|
314
|
+
'pendingCommits',
|
|
315
|
+
'subscriptions',
|
|
316
|
+
'subscription',
|
|
317
|
+
'connectRealtime',
|
|
318
|
+
'disconnectRealtime',
|
|
319
|
+
'setPresence',
|
|
320
|
+
'presence',
|
|
321
|
+
'uploadBlob',
|
|
322
|
+
'fetchBlob',
|
|
323
|
+
];
|
|
303
324
|
const api = {
|
|
304
|
-
|
|
325
|
+
...Object.fromEntries(coreMethods.map((method) => [
|
|
326
|
+
method,
|
|
327
|
+
(...args) => {
|
|
328
|
+
const running = requireClient();
|
|
329
|
+
return Reflect.apply(running[method], running, args);
|
|
330
|
+
},
|
|
331
|
+
])),
|
|
305
332
|
beginSecurityPreflight: async () => {
|
|
306
333
|
if (backgroundTimer !== undefined)
|
|
307
334
|
clearTimeout(backgroundTimer);
|
|
@@ -317,12 +344,9 @@ export function startSyncWorker(overrides = {}) {
|
|
|
317
344
|
: {}),
|
|
318
345
|
});
|
|
319
346
|
},
|
|
320
|
-
subscribe: (input) => requireClient().subscribe(input),
|
|
321
|
-
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
322
347
|
setWindow: async (base, units) => {
|
|
323
348
|
await requireClient().setWindowCommand(base, units);
|
|
324
349
|
},
|
|
325
|
-
windowState: (base) => requireClient().windowState(base),
|
|
326
350
|
mutate: (mutations) => {
|
|
327
351
|
return requireClient().mutateCommand(mutations).value;
|
|
328
352
|
},
|
|
@@ -332,11 +356,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
332
356
|
const result = requireClient().patchCommand(table, rowId, partial, options);
|
|
333
357
|
return result.value;
|
|
334
358
|
},
|
|
335
|
-
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
336
|
-
rebootstrapLocalData: (input) => requireClient().rebootstrapLocalData(input),
|
|
337
|
-
previousVersionSnapshot: (spec) => requireClient().previousVersionSnapshot(spec),
|
|
338
|
-
previousVersionAudit: () => requireClient().previousVersionAudit(),
|
|
339
|
-
previousVersionDiscard: () => requireClient().previousVersionDiscard(),
|
|
340
359
|
sync: () => {
|
|
341
360
|
const running = requireClient();
|
|
342
361
|
return serializedSync(() => running.sync());
|
|
@@ -345,11 +364,7 @@ export function startSyncWorker(overrides = {}) {
|
|
|
345
364
|
const running = requireClient();
|
|
346
365
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
347
366
|
},
|
|
348
|
-
query: (sql, params) => requireClient().query(sql, params),
|
|
349
|
-
authoritySnapshot: () => requireClient().authoritySnapshot(),
|
|
350
|
-
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
351
367
|
localRevision: () => requireClient().localRevision,
|
|
352
|
-
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
353
368
|
diagnosticsSnapshot: (request) => {
|
|
354
369
|
const snapshot = requireClient().diagnosticsSnapshot(request);
|
|
355
370
|
return withClientDiagnosticsHost(snapshot, {
|
|
@@ -359,20 +374,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
359
374
|
realtime: snapshot.host.realtime,
|
|
360
375
|
});
|
|
361
376
|
},
|
|
362
|
-
conflicts: () => requireClient().conflicts(),
|
|
363
|
-
rejections: () => requireClient().rejections(),
|
|
364
|
-
commitOutcome: (clientCommitId) => requireClient().commitOutcome(clientCommitId),
|
|
365
|
-
commitOutcomes: (query) => requireClient().commitOutcomes(query),
|
|
366
|
-
resolveCommitOutcome: (input) => requireClient().resolveCommitOutcome(input),
|
|
367
|
-
pendingCommits: () => requireClient().pendingCommits(),
|
|
368
|
-
subscriptions: () => requireClient().subscriptions(),
|
|
369
|
-
subscription: (id) => requireClient().subscription(id),
|
|
370
|
-
connectRealtime: () => requireClient().connectRealtime(),
|
|
371
|
-
disconnectRealtime: () => requireClient().disconnectRealtime(),
|
|
372
|
-
setPresence: (scopeKey, doc) => requireClient().setPresence(scopeKey, doc),
|
|
373
|
-
presence: (scopeKey) => requireClient().presence(scopeKey),
|
|
374
|
-
uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
|
|
375
|
-
fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
|
|
376
377
|
setHeaders: (next) => {
|
|
377
378
|
headers = next;
|
|
378
379
|
},
|
|
@@ -425,8 +426,6 @@ export function startSyncWorker(overrides = {}) {
|
|
|
425
426
|
'previousVersionDiscard',
|
|
426
427
|
]);
|
|
427
428
|
async function dispatch(message) {
|
|
428
|
-
if (message.method === 'authoritySnapshot' && message.args.length)
|
|
429
|
-
throw new ClientSyncError('client.authority_read_forbidden', 'authoritySnapshot accepts no arguments');
|
|
430
429
|
const method = api[message.method];
|
|
431
430
|
const invoke = () => method.apply(api, message.args);
|
|
432
431
|
if (syncChainMethods.has(message.method)) {
|
package/dist/worker-host.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { PromiseMethods } from './client.js';
|
|
|
25
25
|
*/
|
|
26
26
|
import type { WakeReason } from '@syncular/core';
|
|
27
27
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
28
|
-
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
28
|
+
import type { ConflictRecord, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SecurityLifecycle, SnapshotRead, SnapshotReadRequest, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
|
|
29
29
|
import type { SqlRow, SqlValue } from './database.js';
|
|
30
30
|
import { ClientDiagnosticsEmitter, type ClientDiagnosticsListener, type ClientDiagnosticsRequest, type ClientDiagnosticsSnapshot, type RealtimePolicy } from './diagnostics.js';
|
|
31
31
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -244,10 +244,11 @@ export declare class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
244
244
|
discarded: boolean;
|
|
245
245
|
}>;
|
|
246
246
|
sync(): Promise<SyncSummary>;
|
|
247
|
-
syncUntilIdle(maxRounds?: number): Promise<
|
|
247
|
+
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
248
248
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
249
249
|
authoritySnapshot(): Promise<import('./authority.js').AuthoritySnapshot>;
|
|
250
250
|
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
251
|
+
snapshotRead<Row = SqlRow>(spec: SnapshotReadRequest): Promise<SnapshotRead<Row>>;
|
|
251
252
|
localRevision(): Promise<LocalRevision>;
|
|
252
253
|
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
253
254
|
diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
|
package/dist/worker-host.js
CHANGED
|
@@ -340,6 +340,9 @@ export class SyncClientHandle {
|
|
|
340
340
|
querySnapshot(spec) {
|
|
341
341
|
return this.#call('querySnapshot', [spec]);
|
|
342
342
|
}
|
|
343
|
+
snapshotRead(spec) {
|
|
344
|
+
return this.#call('snapshotRead', [spec]);
|
|
345
|
+
}
|
|
343
346
|
localRevision() {
|
|
344
347
|
return this.#call('localRevision', []);
|
|
345
348
|
}
|
|
@@ -19,7 +19,7 @@ import type { SyncProgress } from './progress.js';
|
|
|
19
19
|
*/
|
|
20
20
|
import type { WakeReason } from '@syncular/core';
|
|
21
21
|
import type { BlobRef, CachedBlob } from './blob.js';
|
|
22
|
-
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, WindowState } from './client.js';
|
|
22
|
+
import type { ConflictRecord, ClientSnapshotMethods, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SubscribeInput, SyncClientConfig, SyncClientLimits, SyncSummary, SyncIdleResult, WindowState } from './client.js';
|
|
23
23
|
import type { SqlRow, SqlValue } from './database.js';
|
|
24
24
|
import type { ClientDiagnosticsSnapshot, RealtimePolicy } from './diagnostics.js';
|
|
25
25
|
import type { EncryptionKeyringConfig } from './encryption.js';
|
|
@@ -137,7 +137,7 @@ export interface WorkerApi extends Omit<ClientSnapshotMethods, 'querySnapshot'>
|
|
|
137
137
|
discarded: boolean;
|
|
138
138
|
};
|
|
139
139
|
sync(): Promise<SyncSummary>;
|
|
140
|
-
syncUntilIdle(maxRounds?: number): Promise<
|
|
140
|
+
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
141
141
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
142
142
|
authoritySnapshot(): import('./authority.js').AuthoritySnapshot;
|
|
143
143
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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",
|
|
@@ -73,6 +73,14 @@
|
|
|
73
73
|
"types": "./dist/worker-entry.d.ts",
|
|
74
74
|
"default": "./dist/worker-entry.js"
|
|
75
75
|
}
|
|
76
|
+
},
|
|
77
|
+
"./authority": {
|
|
78
|
+
"bun": "./src/authority.ts",
|
|
79
|
+
"browser": "./dist/authority.js",
|
|
80
|
+
"import": {
|
|
81
|
+
"types": "./dist/authority.d.ts",
|
|
82
|
+
"default": "./dist/authority.js"
|
|
83
|
+
}
|
|
76
84
|
}
|
|
77
85
|
},
|
|
78
86
|
"files": [
|
|
@@ -89,10 +97,10 @@
|
|
|
89
97
|
},
|
|
90
98
|
"dependencies": {
|
|
91
99
|
"@sqlite.org/sqlite-wasm": "^3.53.0-build1",
|
|
92
|
-
"@syncular/core": "0.
|
|
100
|
+
"@syncular/core": "0.31.0"
|
|
93
101
|
},
|
|
94
102
|
"devDependencies": {
|
|
95
|
-
"@syncular/server": "0.
|
|
103
|
+
"@syncular/server": "0.31.0",
|
|
96
104
|
"playwright": "1.63.0",
|
|
97
105
|
"@electric-sql/pglite": "^0.5.4"
|
|
98
106
|
}
|