@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/src/outcomes.ts
CHANGED
|
@@ -9,11 +9,16 @@ import { RETAINED_ROWS } from './failed-overlay';
|
|
|
9
9
|
* restart can never turn "rejected" into an inferred success. Conflict payloads
|
|
10
10
|
* deliberately stay local; retention never deletes an unresolved failure.
|
|
11
11
|
*/
|
|
12
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
normalizeRejectionDetails,
|
|
14
|
+
type RejectionDetails,
|
|
15
|
+
type RowValue,
|
|
16
|
+
} from '@syncular/core';
|
|
13
17
|
import type { ClientDatabase } from './database';
|
|
14
18
|
import { ClientSyncError } from './errors';
|
|
15
19
|
import type { OutboxOperation } from './outbox';
|
|
16
20
|
import {
|
|
21
|
+
isBytesEnvelope,
|
|
17
22
|
mapRowValues,
|
|
18
23
|
type JsonRowValue,
|
|
19
24
|
jsonToRowValue,
|
|
@@ -155,61 +160,168 @@ function encodeResults(results: readonly CommitOperationOutcome[]): string {
|
|
|
155
160
|
return JSON.stringify(stored);
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/** A JSON object that is neither null nor an array. */
|
|
164
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
165
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isStoredRow(value: unknown): value is Record<string, JsonRowValue> {
|
|
169
|
+
return (
|
|
170
|
+
isRecord(value) &&
|
|
171
|
+
Object.values(value).every(
|
|
172
|
+
(cell) =>
|
|
173
|
+
cell === null ||
|
|
174
|
+
typeof cell === 'string' ||
|
|
175
|
+
typeof cell === 'boolean' ||
|
|
176
|
+
(typeof cell === 'number' && Number.isFinite(cell)) ||
|
|
177
|
+
isBytesEnvelope(cell),
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function isStoredOperation(value: unknown): value is OutboxOperation {
|
|
183
|
+
return (
|
|
184
|
+
isRecord(value) &&
|
|
185
|
+
typeof value.table === 'string' &&
|
|
186
|
+
typeof value.rowId === 'string' &&
|
|
187
|
+
(value.baseVersion === undefined ||
|
|
188
|
+
Number.isSafeInteger(value.baseVersion)) &&
|
|
189
|
+
((value.op === 'upsert' && isStoredRow(value.values)) ||
|
|
190
|
+
(value.op === 'delete' && value.values === undefined))
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
158
194
|
function decodeResults(raw: string): CommitOperationOutcome[] {
|
|
195
|
+
function corrupt(): never {
|
|
196
|
+
throw new ClientSyncError(
|
|
197
|
+
'sync.local_corrupt',
|
|
198
|
+
'persisted commit outcome is invalid',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
159
201
|
const stored = JSON.parse(raw) as StoredCommitOperationOutcome[];
|
|
202
|
+
if (!Array.isArray(stored)) corrupt();
|
|
160
203
|
return stored.map((result) => {
|
|
161
|
-
if (result
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
204
|
+
if (!isRecord(result)) corrupt();
|
|
205
|
+
if (result.status === 'applied') {
|
|
206
|
+
if (
|
|
207
|
+
!Number.isInteger(result.opIndex) ||
|
|
208
|
+
result.opIndex < -2147483648 ||
|
|
209
|
+
result.opIndex > 2147483647
|
|
210
|
+
)
|
|
211
|
+
corrupt();
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
if (result.status === 'conflict') {
|
|
215
|
+
const conflict = result.conflict;
|
|
216
|
+
if (
|
|
217
|
+
!isRecord(conflict) ||
|
|
218
|
+
!isStoredRow(conflict.serverRow) ||
|
|
219
|
+
typeof conflict.clientCommitId !== 'string' ||
|
|
220
|
+
!Number.isInteger(conflict.opIndex) ||
|
|
221
|
+
conflict.opIndex < -2147483648 ||
|
|
222
|
+
conflict.opIndex > 2147483647 ||
|
|
223
|
+
typeof conflict.table !== 'string' ||
|
|
224
|
+
typeof conflict.rowId !== 'string' ||
|
|
225
|
+
typeof conflict.code !== 'string' ||
|
|
226
|
+
typeof conflict.message !== 'string' ||
|
|
227
|
+
(conflict.operation !== undefined &&
|
|
228
|
+
!isStoredOperation(conflict.operation)) ||
|
|
229
|
+
!Number.isSafeInteger(conflict.serverVersion) ||
|
|
230
|
+
(conflict.conflictColumns !== undefined &&
|
|
231
|
+
(!Array.isArray(conflict.conflictColumns) ||
|
|
232
|
+
!conflict.conflictColumns.every(
|
|
233
|
+
(column) => typeof column === 'string',
|
|
234
|
+
)))
|
|
235
|
+
) {
|
|
236
|
+
corrupt();
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
status: 'conflict',
|
|
240
|
+
conflict: {
|
|
241
|
+
...conflict,
|
|
242
|
+
// Pre-column-version journal entries carry no conflictColumns.
|
|
243
|
+
conflictColumns: conflict.conflictColumns ?? [],
|
|
244
|
+
serverRow: mapRowValues(conflict.serverRow, jsonToRowValue),
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (result.status === 'error') {
|
|
249
|
+
const rejection = result.rejection;
|
|
250
|
+
if (
|
|
251
|
+
!isRecord(rejection) ||
|
|
252
|
+
typeof rejection.clientCommitId !== 'string' ||
|
|
253
|
+
!Number.isInteger(rejection.opIndex) ||
|
|
254
|
+
rejection.opIndex < -2147483648 ||
|
|
255
|
+
rejection.opIndex > 2147483647 ||
|
|
256
|
+
typeof rejection.code !== 'string' ||
|
|
257
|
+
typeof rejection.message !== 'string' ||
|
|
258
|
+
typeof rejection.retryable !== 'boolean' ||
|
|
259
|
+
(rejection.operation !== undefined &&
|
|
260
|
+
!isStoredOperation(rejection.operation))
|
|
261
|
+
) {
|
|
262
|
+
corrupt();
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const { details, ...fields } = rejection;
|
|
266
|
+
return {
|
|
267
|
+
status: 'error',
|
|
268
|
+
rejection: {
|
|
269
|
+
...fields,
|
|
270
|
+
...(details == null
|
|
271
|
+
? {}
|
|
272
|
+
: { details: normalizeRejectionDetails(details) }),
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
} catch {
|
|
276
|
+
corrupt();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
corrupt();
|
|
171
280
|
});
|
|
172
281
|
}
|
|
173
282
|
|
|
174
283
|
function parseOutcome(
|
|
175
284
|
db: ClientDatabase,
|
|
176
285
|
row: Readonly<Record<string, unknown>>,
|
|
286
|
+
attachRetainedRows = true,
|
|
177
287
|
): CommitOutcome {
|
|
178
|
-
const retainedRows: RetainedCommitRow[] =
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
Object.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
288
|
+
const retainedRows: RetainedCommitRow[] = attachRetainedRows
|
|
289
|
+
? db
|
|
290
|
+
.query(
|
|
291
|
+
`SELECT tbl,id,intent,base,version,unique_conflicts FROM ${RETAINED_ROWS} WHERE commit_seq IS NULL AND commit_id=? ORDER BY idx`,
|
|
292
|
+
[row.client_commit_id as string],
|
|
293
|
+
)
|
|
294
|
+
.map((retained) => {
|
|
295
|
+
const decode = (value: unknown): Record<string, RowValue> | null =>
|
|
296
|
+
value === null
|
|
297
|
+
? null
|
|
298
|
+
: Object.fromEntries(
|
|
299
|
+
Object.entries(
|
|
300
|
+
JSON.parse(String(value)) as Record<string, JsonRowValue>,
|
|
301
|
+
).map(([key, value]) => [key, jsonToRowValue(value)]),
|
|
302
|
+
);
|
|
303
|
+
return {
|
|
304
|
+
table: retained.tbl as string,
|
|
305
|
+
rowId: retained.id as string,
|
|
306
|
+
localRow: decode(retained.intent),
|
|
307
|
+
...(retained.unique_conflicts === null
|
|
308
|
+
? {}
|
|
309
|
+
: {
|
|
310
|
+
uniqueConflicts: (
|
|
311
|
+
JSON.parse(
|
|
312
|
+
retained.unique_conflicts as string,
|
|
313
|
+
) as NonNullable<RetainedCommitRow['uniqueConflicts']>
|
|
314
|
+
).map((conflict) => ({
|
|
315
|
+
...conflict,
|
|
316
|
+
serverRow: decode(JSON.stringify(conflict.serverRow))!,
|
|
317
|
+
})),
|
|
318
|
+
}),
|
|
319
|
+
serverRow: decode(retained.base),
|
|
320
|
+
serverVersion:
|
|
321
|
+
retained.version === null ? null : (retained.version as number),
|
|
322
|
+
};
|
|
323
|
+
})
|
|
324
|
+
: [];
|
|
213
325
|
return {
|
|
214
326
|
sequence: row.seq as number,
|
|
215
327
|
clientCommitId: row.client_commit_id as string,
|
|
@@ -259,6 +371,78 @@ export function commitOutcome(
|
|
|
259
371
|
return row === undefined ? undefined : parseOutcome(db, row);
|
|
260
372
|
}
|
|
261
373
|
|
|
374
|
+
/**
|
|
375
|
+
* §7.5 persisted-journal view: the stored outcome WITHOUT the owner-derived
|
|
376
|
+
* `retainedRows` images. A malformed journal row (invalid status, resolution,
|
|
377
|
+
* results, or operations JSON) raises the same static `sync.local_corrupt` the
|
|
378
|
+
* native sidecar reports, never a raw `SyntaxError`.
|
|
379
|
+
*/
|
|
380
|
+
export function persistedCommitOutcome(
|
|
381
|
+
db: ClientDatabase,
|
|
382
|
+
clientCommitId: string,
|
|
383
|
+
): CommitOutcome | undefined {
|
|
384
|
+
const row = db.query(`${SELECT_OUTCOME} WHERE client_commit_id=?`, [
|
|
385
|
+
clientCommitId,
|
|
386
|
+
])[0];
|
|
387
|
+
if (row === undefined) return undefined;
|
|
388
|
+
function corrupt(): never {
|
|
389
|
+
throw new ClientSyncError(
|
|
390
|
+
'sync.local_corrupt',
|
|
391
|
+
'persisted commit outcome is invalid',
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const status = row.status;
|
|
395
|
+
const resolution = row.resolution;
|
|
396
|
+
if (
|
|
397
|
+
(status !== 'applied' &&
|
|
398
|
+
status !== 'cached' &&
|
|
399
|
+
status !== 'conflict' &&
|
|
400
|
+
status !== 'rejected') ||
|
|
401
|
+
(resolution !== 'active' &&
|
|
402
|
+
resolution !== 'resolved_keep_server' &&
|
|
403
|
+
resolution !== 'superseded' &&
|
|
404
|
+
resolution !== 'dismissed') ||
|
|
405
|
+
!Number.isSafeInteger(row.seq) ||
|
|
406
|
+
typeof row.client_commit_id !== 'string' ||
|
|
407
|
+
!Number.isSafeInteger(row.recorded_at_ms) ||
|
|
408
|
+
typeof row.results !== 'string' ||
|
|
409
|
+
(row.operations !== null && typeof row.operations !== 'string') ||
|
|
410
|
+
(row.resolved_at_ms !== null &&
|
|
411
|
+
!Number.isSafeInteger(row.resolved_at_ms)) ||
|
|
412
|
+
(row.replacement_client_commit_id !== null &&
|
|
413
|
+
typeof row.replacement_client_commit_id !== 'string')
|
|
414
|
+
) {
|
|
415
|
+
corrupt();
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const outcome = parseOutcome(db, row, false);
|
|
419
|
+
const hasConflict = outcome.results.some(
|
|
420
|
+
(result) => result.status === 'conflict',
|
|
421
|
+
);
|
|
422
|
+
const hasError = outcome.results.some(
|
|
423
|
+
(result) => result.status === 'error',
|
|
424
|
+
);
|
|
425
|
+
const redacted =
|
|
426
|
+
outcome.results.length === 0 && outcome.operations === undefined;
|
|
427
|
+
if (
|
|
428
|
+
((status === 'applied' || status === 'cached') &&
|
|
429
|
+
(hasConflict || hasError)) ||
|
|
430
|
+
(status === 'conflict' && !(hasConflict || redacted)) ||
|
|
431
|
+
(status === 'rejected' && (hasConflict || !(hasError || redacted)))
|
|
432
|
+
)
|
|
433
|
+
corrupt();
|
|
434
|
+
if (
|
|
435
|
+
outcome.operations !== undefined &&
|
|
436
|
+
(!Array.isArray(outcome.operations) ||
|
|
437
|
+
!outcome.operations.every(isStoredOperation))
|
|
438
|
+
)
|
|
439
|
+
corrupt();
|
|
440
|
+
return outcome;
|
|
441
|
+
} catch {
|
|
442
|
+
corrupt();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
262
446
|
export function listCommitOutcomes(
|
|
263
447
|
db: ClientDatabase,
|
|
264
448
|
query: CommitOutcomeQuery = {},
|
package/src/schema.ts
CHANGED
|
@@ -105,6 +105,19 @@ function invalidSchema(message: string): never {
|
|
|
105
105
|
export function compileClientSchema(
|
|
106
106
|
schema: ClientSchema,
|
|
107
107
|
): CompiledClientSchema {
|
|
108
|
+
// §7.4.2: the persisted marker is a canonical positive i32 and a replica
|
|
109
|
+
// refuses any requested version outside that range before it touches
|
|
110
|
+
// storage, so a client can never persist a marker it would reject on the
|
|
111
|
+
// next open.
|
|
112
|
+
if (
|
|
113
|
+
!Number.isInteger(schema.version) ||
|
|
114
|
+
schema.version < 1 ||
|
|
115
|
+
schema.version > 2147483647
|
|
116
|
+
) {
|
|
117
|
+
throw invalidRequest(
|
|
118
|
+
'generated schema version must be an integer in 1..=2147483647',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
108
121
|
const tables = new Map<string, CompiledClientTable>();
|
|
109
122
|
const schemaObjectNames = new Set<string>();
|
|
110
123
|
for (const table of schema.tables) {
|
|
@@ -726,7 +739,13 @@ export function coerceSqlRepresentation(
|
|
|
726
739
|
switch (localColumnType(column)) {
|
|
727
740
|
case 'boolean':
|
|
728
741
|
return value === 0 ? false : value === 1 ? true : value;
|
|
729
|
-
case 'integer':
|
|
742
|
+
case 'integer': {
|
|
743
|
+
const integer = typeof value === 'bigint' ? Number(value) : value;
|
|
744
|
+
if (typeof integer === 'number' && !Number.isSafeInteger(integer)) {
|
|
745
|
+
throw invalidRequest('integer value is outside the safe-integer range');
|
|
746
|
+
}
|
|
747
|
+
return integer;
|
|
748
|
+
}
|
|
730
749
|
case 'float':
|
|
731
750
|
return typeof value === 'bigint' ? Number(value) : value;
|
|
732
751
|
default:
|
|
@@ -775,6 +794,24 @@ function bytesToHex(bytes: Uint8Array): string {
|
|
|
775
794
|
return out;
|
|
776
795
|
}
|
|
777
796
|
|
|
797
|
+
/**
|
|
798
|
+
* The canonical §7.1 byte envelope: exactly one `$bytes` key holding a string
|
|
799
|
+
* of hexadecimal digit pairs. Uppercase digits are accepted; encoders emit
|
|
800
|
+
* lowercase. A malformed envelope is a caller error, never a value to coerce.
|
|
801
|
+
*/
|
|
802
|
+
export function isBytesEnvelope(
|
|
803
|
+
value: unknown,
|
|
804
|
+
): value is { readonly $bytes: string } {
|
|
805
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
806
|
+
return false;
|
|
807
|
+
const record = value as Record<string, unknown>;
|
|
808
|
+
return (
|
|
809
|
+
Object.keys(record).length === 1 &&
|
|
810
|
+
typeof record.$bytes === 'string' &&
|
|
811
|
+
/^(?:[0-9a-fA-F]{2})*$/.test(record.$bytes)
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
|
|
778
815
|
export function hexToBytes(hex: string): Uint8Array {
|
|
779
816
|
const out = new Uint8Array(hex.length / 2);
|
|
780
817
|
for (let i = 0; i < out.length; i++) {
|
|
@@ -790,6 +827,10 @@ export function rowValueToJson(value: RowValue): JsonRowValue {
|
|
|
790
827
|
|
|
791
828
|
export function jsonToRowValue(value: JsonRowValue): RowValue {
|
|
792
829
|
if (typeof value === 'object' && value !== null) {
|
|
830
|
+
if (!isBytesEnvelope(value))
|
|
831
|
+
throw invalidRequest(
|
|
832
|
+
'an object value must be one $bytes key holding an even-length hexadecimal string',
|
|
833
|
+
);
|
|
793
834
|
return hexToBytes(value.$bytes);
|
|
794
835
|
}
|
|
795
836
|
return value;
|
package/src/state.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const SUBSCRIPTIONS_TABLE = '_syncular_subscriptions';
|
|
|
9
9
|
* exactly for that purpose.
|
|
10
10
|
*/
|
|
11
11
|
import type { ScopeMap } from '@syncular/core';
|
|
12
|
+
import { ClientSyncError } from './errors';
|
|
12
13
|
import type { CompiledClientSchema } from './schema';
|
|
13
14
|
import type { ClientDatabase } from './database';
|
|
14
15
|
import type { LocalRevision } from './invalidation';
|
|
@@ -225,12 +226,18 @@ export function setMeta(db: ClientDatabase, key: string, value: string): void {
|
|
|
225
226
|
export function getLocalRevision(db: ClientDatabase): LocalRevision {
|
|
226
227
|
const raw = getMeta(db, LOCAL_REVISION_KEY);
|
|
227
228
|
if (raw === undefined) return 0n;
|
|
228
|
-
if (!/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
229
|
-
throw new
|
|
229
|
+
if (typeof raw !== 'string' || !/^(0|[1-9][0-9]*)$/.test(raw)) {
|
|
230
|
+
throw new ClientSyncError(
|
|
231
|
+
'sync.local_corrupt',
|
|
232
|
+
'persisted local revision is invalid',
|
|
233
|
+
);
|
|
230
234
|
}
|
|
231
235
|
const revision = BigInt(raw);
|
|
232
236
|
if (revision > MAX_U64) {
|
|
233
|
-
throw new
|
|
237
|
+
throw new ClientSyncError(
|
|
238
|
+
'sync.local_corrupt',
|
|
239
|
+
'persisted local revision is invalid',
|
|
240
|
+
);
|
|
234
241
|
}
|
|
235
242
|
return revision;
|
|
236
243
|
}
|
|
@@ -242,7 +249,10 @@ export function getLocalRevision(db: ClientDatabase): LocalRevision {
|
|
|
242
249
|
export function bumpLocalRevision(db: ClientDatabase): LocalRevision {
|
|
243
250
|
const current = getLocalRevision(db);
|
|
244
251
|
if (current === MAX_U64) {
|
|
245
|
-
throw new
|
|
252
|
+
throw new ClientSyncError(
|
|
253
|
+
'sync.local_corrupt',
|
|
254
|
+
'local revision exhausted u64',
|
|
255
|
+
);
|
|
246
256
|
}
|
|
247
257
|
const next = current + 1n;
|
|
248
258
|
setMeta(db, LOCAL_REVISION_KEY, next.toString());
|
package/src/sync-scheduler.ts
CHANGED
|
@@ -87,6 +87,16 @@ export function installSyncScheduler(
|
|
|
87
87
|
backgroundReady = false;
|
|
88
88
|
void client
|
|
89
89
|
.syncUntilIdle(options.maxRounds)
|
|
90
|
+
.then((result) => {
|
|
91
|
+
if (
|
|
92
|
+
result !== null &&
|
|
93
|
+
typeof result === 'object' &&
|
|
94
|
+
'budgetExhausted' in result &&
|
|
95
|
+
result.budgetExhausted === true
|
|
96
|
+
) {
|
|
97
|
+
queueImmediate();
|
|
98
|
+
}
|
|
99
|
+
})
|
|
90
100
|
.catch((error: unknown) => {
|
|
91
101
|
if (!stopped) report(error);
|
|
92
102
|
})
|
package/src/worker-entry.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* RPC-driven and auto-driven sync rounds serialize on one queue because
|
|
14
14
|
* the core owns exactly one loop.
|
|
15
15
|
*/
|
|
16
|
-
import { defineAuthorityReads } from './authority';
|
|
16
|
+
import type { defineAuthorityReads } from './authority';
|
|
17
17
|
import { SyncClient } from './client';
|
|
18
18
|
import type { ClientDatabase } from './database';
|
|
19
19
|
import {
|
|
@@ -52,6 +52,8 @@ import {
|
|
|
52
52
|
} from './worker-protocol';
|
|
53
53
|
|
|
54
54
|
export interface SyncWorkerOverrides {
|
|
55
|
+
/** Explicit authority reader factory. Required when init declares authorityReads. */
|
|
56
|
+
readonly createAuthorityReads?: typeof defineAuthorityReads;
|
|
55
57
|
/** Database factory indirection: tests inject bun:sqlite here. */
|
|
56
58
|
readonly openDatabase?: (
|
|
57
59
|
config: WorkerInitConfig,
|
|
@@ -175,6 +177,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
175
177
|
void serializedSync(() => running.syncUntilIdle())
|
|
176
178
|
.then((summary) => {
|
|
177
179
|
if (!closed) post({ t: 'event', event: { kind: 'synced', summary } });
|
|
180
|
+
if (summary.budgetExhausted) consumeSyncIntent({ kind: 'interactive' });
|
|
178
181
|
})
|
|
179
182
|
.catch((error: unknown) => {
|
|
180
183
|
if (!closed) {
|
|
@@ -239,6 +242,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
239
242
|
}
|
|
240
243
|
|
|
241
244
|
async function init(config: WorkerInitConfig): Promise<WorkerInitResult> {
|
|
245
|
+
if (config.authorityReads !== undefined && !overrides.createAuthorityReads)
|
|
246
|
+
throw new ClientSyncError(
|
|
247
|
+
'client.authority_read_forbidden',
|
|
248
|
+
'authorityReads requires startSyncWorker({ createAuthorityReads })',
|
|
249
|
+
);
|
|
242
250
|
if (client !== undefined) {
|
|
243
251
|
throw new ClientSyncError(
|
|
244
252
|
WORKER_FAILED_CODE,
|
|
@@ -325,7 +333,11 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
325
333
|
? { encryption: encryptionConfigFromKeyring(config.encryption) }
|
|
326
334
|
: {}),
|
|
327
335
|
...(config.authorityReads !== undefined
|
|
328
|
-
? {
|
|
336
|
+
? {
|
|
337
|
+
authorityReads: overrides.createAuthorityReads!(
|
|
338
|
+
config.authorityReads,
|
|
339
|
+
),
|
|
340
|
+
}
|
|
329
341
|
: {}),
|
|
330
342
|
...(config.securityPreflight !== undefined
|
|
331
343
|
? { securityPreflight: config.securityPreflight }
|
|
@@ -393,8 +405,47 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
393
405
|
return { clientId: started.clientId };
|
|
394
406
|
}
|
|
395
407
|
|
|
408
|
+
// Forward every argument to core methods, including authority's zero-argument check.
|
|
409
|
+
const coreMethods = [
|
|
410
|
+
'securityLifecycle',
|
|
411
|
+
'subscribe',
|
|
412
|
+
'unsubscribe',
|
|
413
|
+
'windowState',
|
|
414
|
+
'purgeLocalData',
|
|
415
|
+
'rebootstrapLocalData',
|
|
416
|
+
'previousVersionSnapshot',
|
|
417
|
+
'previousVersionAudit',
|
|
418
|
+
'previousVersionDiscard',
|
|
419
|
+
'query',
|
|
420
|
+
'authoritySnapshot',
|
|
421
|
+
'querySnapshot',
|
|
422
|
+
'snapshotRead',
|
|
423
|
+
'statusSnapshot',
|
|
424
|
+
'conflicts',
|
|
425
|
+
'rejections',
|
|
426
|
+
'commitOutcome',
|
|
427
|
+
'commitOutcomes',
|
|
428
|
+
'resolveCommitOutcome',
|
|
429
|
+
'pendingCommits',
|
|
430
|
+
'subscriptions',
|
|
431
|
+
'subscription',
|
|
432
|
+
'connectRealtime',
|
|
433
|
+
'disconnectRealtime',
|
|
434
|
+
'setPresence',
|
|
435
|
+
'presence',
|
|
436
|
+
'uploadBlob',
|
|
437
|
+
'fetchBlob',
|
|
438
|
+
] as const satisfies readonly (keyof WorkerApi & keyof SyncClient)[];
|
|
396
439
|
const api: WorkerApi = {
|
|
397
|
-
|
|
440
|
+
...(Object.fromEntries(
|
|
441
|
+
coreMethods.map((method) => [
|
|
442
|
+
method,
|
|
443
|
+
(...args: unknown[]) => {
|
|
444
|
+
const running = requireClient();
|
|
445
|
+
return Reflect.apply(running[method], running, args);
|
|
446
|
+
},
|
|
447
|
+
]),
|
|
448
|
+
) as Pick<WorkerApi, (typeof coreMethods)[number]>),
|
|
398
449
|
beginSecurityPreflight: async () => {
|
|
399
450
|
if (backgroundTimer !== undefined) clearTimeout(backgroundTimer);
|
|
400
451
|
backgroundTimer = undefined;
|
|
@@ -409,12 +460,9 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
409
460
|
: {}),
|
|
410
461
|
});
|
|
411
462
|
},
|
|
412
|
-
subscribe: (input) => requireClient().subscribe(input),
|
|
413
|
-
unsubscribe: (id) => requireClient().unsubscribe(id),
|
|
414
463
|
setWindow: async (base, units) => {
|
|
415
464
|
await requireClient().setWindowCommand(base, units);
|
|
416
465
|
},
|
|
417
|
-
windowState: (base) => requireClient().windowState(base),
|
|
418
466
|
mutate: (mutations) => {
|
|
419
467
|
return requireClient().mutateCommand(mutations).value;
|
|
420
468
|
},
|
|
@@ -429,13 +477,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
429
477
|
);
|
|
430
478
|
return result.value;
|
|
431
479
|
},
|
|
432
|
-
purgeLocalData: (input) => requireClient().purgeLocalData(input),
|
|
433
|
-
rebootstrapLocalData: (input) =>
|
|
434
|
-
requireClient().rebootstrapLocalData(input),
|
|
435
|
-
previousVersionSnapshot: (spec) =>
|
|
436
|
-
requireClient().previousVersionSnapshot(spec),
|
|
437
|
-
previousVersionAudit: () => requireClient().previousVersionAudit(),
|
|
438
|
-
previousVersionDiscard: () => requireClient().previousVersionDiscard(),
|
|
439
480
|
sync: () => {
|
|
440
481
|
const running = requireClient();
|
|
441
482
|
return serializedSync(() => running.sync());
|
|
@@ -444,11 +485,7 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
444
485
|
const running = requireClient();
|
|
445
486
|
return serializedSync(() => running.syncUntilIdle(maxRounds));
|
|
446
487
|
},
|
|
447
|
-
query: (sql, params) => requireClient().query(sql, params),
|
|
448
|
-
authoritySnapshot: () => requireClient().authoritySnapshot(),
|
|
449
|
-
querySnapshot: (spec) => requireClient().querySnapshot(spec),
|
|
450
488
|
localRevision: () => requireClient().localRevision,
|
|
451
|
-
statusSnapshot: () => requireClient().statusSnapshot(),
|
|
452
489
|
diagnosticsSnapshot: (request) => {
|
|
453
490
|
const snapshot = requireClient().diagnosticsSnapshot(request);
|
|
454
491
|
return withClientDiagnosticsHost(snapshot, {
|
|
@@ -458,22 +495,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
458
495
|
realtime: snapshot.host.realtime,
|
|
459
496
|
});
|
|
460
497
|
},
|
|
461
|
-
conflicts: () => requireClient().conflicts(),
|
|
462
|
-
rejections: () => requireClient().rejections(),
|
|
463
|
-
commitOutcome: (clientCommitId) =>
|
|
464
|
-
requireClient().commitOutcome(clientCommitId),
|
|
465
|
-
commitOutcomes: (query) => requireClient().commitOutcomes(query),
|
|
466
|
-
resolveCommitOutcome: (input) =>
|
|
467
|
-
requireClient().resolveCommitOutcome(input),
|
|
468
|
-
pendingCommits: () => requireClient().pendingCommits(),
|
|
469
|
-
subscriptions: () => requireClient().subscriptions(),
|
|
470
|
-
subscription: (id) => requireClient().subscription(id),
|
|
471
|
-
connectRealtime: () => requireClient().connectRealtime(),
|
|
472
|
-
disconnectRealtime: () => requireClient().disconnectRealtime(),
|
|
473
|
-
setPresence: (scopeKey, doc) => requireClient().setPresence(scopeKey, doc),
|
|
474
|
-
presence: (scopeKey) => requireClient().presence(scopeKey),
|
|
475
|
-
uploadBlob: (bytes, options) => requireClient().uploadBlob(bytes, options),
|
|
476
|
-
fetchBlob: (blobIdOrRef) => requireClient().fetchBlob(blobIdOrRef),
|
|
477
498
|
setHeaders: (next) => {
|
|
478
499
|
headers = next;
|
|
479
500
|
},
|
|
@@ -525,11 +546,6 @@ export function startSyncWorker(overrides: SyncWorkerOverrides = {}): void {
|
|
|
525
546
|
]);
|
|
526
547
|
|
|
527
548
|
async function dispatch(message: WorkerCallMessage): Promise<unknown> {
|
|
528
|
-
if (message.method === 'authoritySnapshot' && message.args.length)
|
|
529
|
-
throw new ClientSyncError(
|
|
530
|
-
'client.authority_read_forbidden',
|
|
531
|
-
'authoritySnapshot accepts no arguments',
|
|
532
|
-
);
|
|
533
549
|
const method = api[message.method] as (...args: unknown[]) => unknown;
|
|
534
550
|
const invoke = () => method.apply(api, message.args as unknown[]);
|
|
535
551
|
if (syncChainMethods.has(message.method)) {
|
package/src/worker-host.ts
CHANGED
|
@@ -37,10 +37,13 @@ import type {
|
|
|
37
37
|
QuerySnapshot,
|
|
38
38
|
RejectionRecord,
|
|
39
39
|
SecurityLifecycle,
|
|
40
|
+
SnapshotRead,
|
|
41
|
+
SnapshotReadRequest,
|
|
40
42
|
SubscribeInput,
|
|
41
43
|
SyncClientConfig,
|
|
42
44
|
SyncClientLimits,
|
|
43
45
|
SyncSummary,
|
|
46
|
+
SyncIdleResult,
|
|
44
47
|
WindowState,
|
|
45
48
|
} from './client';
|
|
46
49
|
import type { SqlRow, SqlValue } from './database';
|
|
@@ -650,7 +653,7 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
650
653
|
return this.#call('sync', []);
|
|
651
654
|
}
|
|
652
655
|
|
|
653
|
-
syncUntilIdle(maxRounds?: number): Promise<
|
|
656
|
+
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult> {
|
|
654
657
|
return this.#call('syncUntilIdle', [maxRounds]);
|
|
655
658
|
}
|
|
656
659
|
|
|
@@ -673,6 +676,12 @@ export class SyncClientHandle implements PromiseMethods<WorkerApi> {
|
|
|
673
676
|
return this.#call('querySnapshot', [spec]) as Promise<QuerySnapshot<Row>>;
|
|
674
677
|
}
|
|
675
678
|
|
|
679
|
+
snapshotRead<Row = SqlRow>(
|
|
680
|
+
spec: SnapshotReadRequest,
|
|
681
|
+
): Promise<SnapshotRead<Row>> {
|
|
682
|
+
return this.#call('snapshotRead', [spec]) as Promise<SnapshotRead<Row>>;
|
|
683
|
+
}
|
|
684
|
+
|
|
676
685
|
localRevision(): Promise<LocalRevision> {
|
|
677
686
|
return this.#call('localRevision', []);
|
|
678
687
|
}
|
package/src/worker-protocol.ts
CHANGED
|
@@ -31,6 +31,7 @@ import type {
|
|
|
31
31
|
SyncClientConfig,
|
|
32
32
|
SyncClientLimits,
|
|
33
33
|
SyncSummary,
|
|
34
|
+
SyncIdleResult,
|
|
34
35
|
WindowState,
|
|
35
36
|
} from './client';
|
|
36
37
|
import type { SqlRow, SqlValue } from './database';
|
|
@@ -185,7 +186,7 @@ export interface WorkerApi extends Omit<
|
|
|
185
186
|
/** RFC 0005 D9: the executable rollback step — drop the container. */
|
|
186
187
|
previousVersionDiscard(): { present: boolean; discarded: boolean };
|
|
187
188
|
sync(): Promise<SyncSummary>;
|
|
188
|
-
syncUntilIdle(maxRounds?: number): Promise<
|
|
189
|
+
syncUntilIdle(maxRounds?: number): Promise<SyncIdleResult>;
|
|
189
190
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
190
191
|
authoritySnapshot(): import('./authority').AuthoritySnapshot;
|
|
191
192
|
querySnapshot(spec: QueryReadSpec): QuerySnapshot;
|