@syncular/client 0.30.16 → 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 +20 -0
- 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/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.js +3 -0
- 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 +3 -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/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 +2 -0
- package/src/worker-host.ts +10 -1
- package/src/worker-protocol.ts +2 -1
package/dist/client.js
CHANGED
|
@@ -14,8 +14,9 @@ import { ProgressEmitter, } from './progress.js';
|
|
|
14
14
|
* ownership behind `LeaderLock`. One combined push+pull request per
|
|
15
15
|
* `sync()` round (§7.2); local reads go straight to the database.
|
|
16
16
|
*/
|
|
17
|
-
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
17
|
+
import { canonicalScopeJson, decodeMessage, decodeRow, decodeRowsSegment, encodeMessage, encodeRow, encodeSparseRow, encodePresencePublish, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimeServerEvent, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
18
18
|
import { EncryptError } from '@syncular/core';
|
|
19
|
+
import { EncodeError } from '@syncular/core';
|
|
19
20
|
import { applyCommitFrame, applyRowsSegment, applySqliteSegment, deleteLocalRow, deleteScopedRows, localScopePredicate, evictScopedRows, yieldToHost, upsertLocalRow, } from './apply.js';
|
|
20
21
|
import { clearPendingUpload, computeBlobId, deleteUnreferencedCachedBlobs, enforceBlobCacheCap, ensureBlobSchema, getCachedBlob, listPendingUploads, parseBlobRef, putCachedBlob, recordPendingUpload, schemaHasBlobs, serializeBlobRef, } from './blob.js';
|
|
21
22
|
import { registerDevtools } from './devtools.js';
|
|
@@ -27,11 +28,11 @@ import { compileLocalDataPurge, localDataPurgeMetaKey, localDataPurgeTargetMatch
|
|
|
27
28
|
import { compileLocalDataRebootstrap, localDataRebootstrapMetaKey, } from './local-rebootstrap.js';
|
|
28
29
|
import { decodeLocalDataRebootstrapReceipt, encodeLocalDataRebootstrapReceipt, } from './local-rebootstrap-receipt.js';
|
|
29
30
|
import { appendOutboxCommit, deleteOutboxCommit, dropOutboxCommitsInScope, encodeOutboxCommit, listOutbox, iterateOutbox, countOutbox, listOutboxBeforeImages, OutboxEncodeError, replaceOutboxBeforeImages, } from './outbox.js';
|
|
30
|
-
import { activeFailureRecords, listCommitOutcomes, persistCommitOutcomeResolution, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
31
|
+
import { activeFailureRecords, isStoredOperation, listCommitOutcomes, persistCommitOutcomeResolution, persistedCommitOutcome, pruneCommitOutcomes, commitOutcome as readCommitOutcome, recordCommitOutcome, } from './outcomes.js';
|
|
31
32
|
import { assertReadOnlyQuery } from './query-guard.js';
|
|
32
33
|
import { buildPreviousVersionAudit, capturePreviousVersion, clearPreviousVersionAudit, clearPreviousVersionRefusal, dropPreviousVersionContainer, loadLocalSchemaDescriptor, PREVIOUS_VERSION_CONTAINER_NAME, PREVIOUS_VERSION_MAX_LIMIT, readPreviousVersionContainer, readPreviousVersionRows, setLocalSchemaDescriptor, storedPreviousVersionAudit, storedPreviousVersionRefusal, withPreviousVersionContainer, writePreviousVersionAudit, writePreviousVersionRefusal, } from './previous-version.js';
|
|
33
|
-
import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
34
|
-
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, } from './state.js';
|
|
34
|
+
import { compileClientSchema, matchesLocalScopes, dropAndRecreateSyncedTables, ensureLocalBookkeepingSchema, ensureLocalSyncedSchema, fromSqlValue, coerceSqlRepresentation, localColumnType, jsonToRowValue, LOCAL_SCHEMA_VERSION_KEY, normalizeRecordKeys, OPTIMISTIC_VERSION, quoteIdent, recordToRowValues, rowValueToJson, SYNC_VERSION_COLUMN, stripSyncColumns, } from './schema.js';
|
|
35
|
+
import { bumpLocalRevision, deleteSubscription, getLocalRevision, getMeta, getSubscription, loadSubscriptions, pruneUnknownSubscriptions, saveSubscriptionScopeSchema, resetSubscriptionsForBump, saveSubscription, setMeta, SUBSCRIPTIONS_TABLE, } from './state.js';
|
|
35
36
|
import { deletePendingEviction, deleteWindowUnit, deriveSubId, getWindowUnitBySubId, insertWindowUnit, loadPendingEvictions, loadWindowUnits, savePendingEviction, unitScopes, windowBaseKey, } from './window.js';
|
|
36
37
|
/** Stable client-local error while protected operations are preflight-gated. */
|
|
37
38
|
export const SECURITY_PREFLIGHT_REQUIRED_CODE = 'client.security_preflight_required';
|
|
@@ -56,6 +57,10 @@ const ACCEPT_SIGNED_URLS = 1 << 3;
|
|
|
56
57
|
* Never a wire code (§10.3) — surfaced through the rejection channel.
|
|
57
58
|
*/
|
|
58
59
|
const OUTBOX_INCOMPATIBLE_CODE = 'sync.outbox_incompatible';
|
|
60
|
+
/** §7.4.4: static text for a persisted commit the current codec refuses. */
|
|
61
|
+
const LEGACY_VALUE_REFUSAL_MESSAGE = 'the persisted commit carries values the current codec refuses';
|
|
62
|
+
/** §7.4.4: bounded reason on every intrinsic value drop. */
|
|
63
|
+
const INVALID_STORED_VALUES_REASON = 'invalid_stored_values';
|
|
59
64
|
/** §6.1 per-request operation cap (matches the server's shipped default —
|
|
60
65
|
* `sync.too_many_operations` above it). The push half sends whole commits in
|
|
61
66
|
* commit order up to this cap and defers the rest to the next round. */
|
|
@@ -232,6 +237,19 @@ export class SyncClient {
|
|
|
232
237
|
throw invalidRequest('transportEnabled must be a boolean');
|
|
233
238
|
}
|
|
234
239
|
this.#transportEnabled = config.transportEnabled !== false;
|
|
240
|
+
for (const [name, value] of [
|
|
241
|
+
['maxPushCommitsPerRequest', config.limits?.maxPushCommitsPerRequest],
|
|
242
|
+
[
|
|
243
|
+
'maxPushOperationsPerRequest',
|
|
244
|
+
config.limits?.maxPushOperationsPerRequest,
|
|
245
|
+
],
|
|
246
|
+
['maxPushRequestBytes', config.limits?.maxPushRequestBytes],
|
|
247
|
+
]) {
|
|
248
|
+
if (value !== undefined &&
|
|
249
|
+
(!Number.isInteger(value) || value < 1 || value > 0xffffffff)) {
|
|
250
|
+
throw new ClientSyncError('sync.invalid_request', 'push limit must be an integer in 1..=4294967295', false, { limit: name });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
235
253
|
if (config.securityPreflight === true && config.encryption !== undefined) {
|
|
236
254
|
throw invalidRequest('securityPreflight and encryption are mutually exclusive; activateSecurity installs keys');
|
|
237
255
|
}
|
|
@@ -283,47 +301,117 @@ export class SyncClient {
|
|
|
283
301
|
return;
|
|
284
302
|
const lock = this.#config.leaderLock ?? singleOwnerLock();
|
|
285
303
|
this.#lease = await lock.acquire(this.#config.lockName ?? 'syncular-leader');
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
304
|
+
// §7.4.2: the entire synchronous storage phase below (marker guard,
|
|
305
|
+
// identity and bookkeeping writes, schema reset/replay, container
|
|
306
|
+
// reconciliation, subscription pruning) runs as one deferred transaction
|
|
307
|
+
// whose first statement is the marker read. That read pins SQLite's
|
|
308
|
+
// snapshot before any write: a replica another process upgraded after the
|
|
309
|
+
// read can never be written over (its snapshot fails with
|
|
310
|
+
// BUSY/BUSY_SNAPSHOT), and one upgraded before the read refuses with
|
|
311
|
+
// `client.schema_downgrade`. The batch emits its change event only after
|
|
312
|
+
// the commit. The onUpgrading callback still reports reset intent.
|
|
313
|
+
let subscriptions;
|
|
314
|
+
try {
|
|
315
|
+
this.#applyBatch(() => {
|
|
316
|
+
// §7.4.1/§7.4.2: this is the transaction's first read, so it pins the
|
|
317
|
+
// snapshot every write below commits under. `undefined` is an absent
|
|
318
|
+
// marker (fresh/legacy); a non-canonical value, a `_syncular_meta` that
|
|
319
|
+
// is not a table, or a duplicated marker row is unreadable state.
|
|
320
|
+
let marker;
|
|
321
|
+
try {
|
|
322
|
+
const meta = this.#db.query("SELECT type FROM sqlite_master WHERE name = '_syncular_meta'")[0];
|
|
323
|
+
if (meta !== undefined) {
|
|
324
|
+
if (meta.type !== 'table')
|
|
325
|
+
throw new Error('invalid metadata table');
|
|
326
|
+
const rows = this.#db.query('SELECT value FROM _syncular_meta WHERE key = ?', [LOCAL_SCHEMA_VERSION_KEY]);
|
|
327
|
+
// A duplicated marker cannot be resolved. Picking one row could
|
|
328
|
+
// select an older version and drive a destructive reset over a
|
|
329
|
+
// newer replica.
|
|
330
|
+
if (rows.length > 1)
|
|
331
|
+
throw new Error('invalid schema marker');
|
|
332
|
+
const row = rows[0];
|
|
333
|
+
if (row === undefined &&
|
|
334
|
+
this.#db.query("SELECT 1 FROM _syncular_meta WHERE key = 'localSchemaDescriptor' LIMIT 1").length > 0) {
|
|
335
|
+
throw new Error('missing paired schema marker');
|
|
336
|
+
}
|
|
337
|
+
if (row !== undefined) {
|
|
338
|
+
const value = row.value;
|
|
339
|
+
const version = Number(value);
|
|
340
|
+
if (typeof value !== 'string' ||
|
|
341
|
+
!Number.isInteger(version) ||
|
|
342
|
+
version < 1 ||
|
|
343
|
+
version > 2147483647 ||
|
|
344
|
+
String(version) !== value) {
|
|
345
|
+
throw new Error('invalid schema marker');
|
|
346
|
+
}
|
|
347
|
+
marker = version;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
// A recognized SQLite storage failure (including BUSY/LOCKED
|
|
353
|
+
// contention on the marker read) stays classified; only an invalid
|
|
354
|
+
// or otherwise unreadable marker is `sync.local_corrupt`.
|
|
355
|
+
const failure = classifySqliteFailure(error);
|
|
356
|
+
if (failure.code !== undefined)
|
|
357
|
+
throw failure.error;
|
|
358
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted local schema marker is unreadable or invalid');
|
|
359
|
+
}
|
|
360
|
+
if (marker !== undefined && marker > this.#schema.version) {
|
|
361
|
+
throw new ClientSyncError('client.schema_downgrade', 'persisted local schema is newer than the requested schema', false, {
|
|
362
|
+
persistedVersion: marker,
|
|
363
|
+
requestedVersion: this.#schema.version,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
// New app indexes may reference columns that only exist after the
|
|
367
|
+
// reset.
|
|
368
|
+
ensureLocalBookkeepingSchema(this.#db);
|
|
369
|
+
if (this.#hasBlobs)
|
|
370
|
+
ensureBlobSchema(this.#db);
|
|
371
|
+
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
372
|
+
const activeFailures = activeFailureRecords(listCommitOutcomes(this.#db, { activeOnly: true }));
|
|
373
|
+
this.#conflicts = activeFailures.conflicts;
|
|
374
|
+
this.#rejections = activeFailures.rejections;
|
|
375
|
+
const persisted = getMeta(this.#db, 'clientId');
|
|
376
|
+
if (persisted !== undefined &&
|
|
377
|
+
this.#config.clientId !== undefined &&
|
|
378
|
+
persisted !== this.#config.clientId) {
|
|
379
|
+
throw new ClientSyncError('client.identity_mismatch', `database clientId ${JSON.stringify(persisted)} differs from ${JSON.stringify(this.#config.clientId)}`);
|
|
380
|
+
}
|
|
381
|
+
this.#clientId =
|
|
382
|
+
persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
383
|
+
if (persisted === undefined) {
|
|
384
|
+
setMeta(this.#db, 'clientId', this.#clientId);
|
|
385
|
+
}
|
|
386
|
+
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
387
|
+
const leaseJson = getMeta(this.#db, 'leaseState');
|
|
388
|
+
if (leaseJson !== undefined) {
|
|
389
|
+
this.#leaseState = JSON.parse(leaseJson);
|
|
390
|
+
}
|
|
391
|
+
// §7.4.2 trigger 1: the persisted local schema version is lower than
|
|
392
|
+
// the generated version this client ships — run the wipe/re-bootstrap
|
|
393
|
+
// reset before the first sync round. A fresh install (no marker) is
|
|
394
|
+
// treated as already at the generated version.
|
|
395
|
+
this.#detectAndResetSchema(marker);
|
|
396
|
+
if (marker === this.#schema.version) {
|
|
397
|
+
this.#replayOutbox(undefined, true, false);
|
|
398
|
+
}
|
|
399
|
+
// RFC 0005 D9: a container with missing/stale metadata, or past its
|
|
400
|
+
// TTL, is discarded at boot before anything can read it; the capture
|
|
401
|
+
// above already ran against this transaction's validated marker. The
|
|
402
|
+
// orphan/stale sweep is unconditional; the TTL is aware-binary hygiene
|
|
403
|
+
// only.
|
|
404
|
+
this.#reconcilePreviousVersionAtBoot();
|
|
405
|
+
// A registration remains app intent only while its table exists in the
|
|
406
|
+
// running schema. Removed-table registrations would poison every pull.
|
|
407
|
+
subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
302
411
|
await this.#lease.release();
|
|
303
412
|
this.#lease = undefined;
|
|
304
|
-
throw
|
|
305
|
-
}
|
|
306
|
-
this.#clientId = persisted ?? this.#config.clientId ?? crypto.randomUUID();
|
|
307
|
-
if (persisted === undefined) {
|
|
308
|
-
setMeta(this.#db, 'clientId', this.#clientId);
|
|
309
|
-
}
|
|
310
|
-
// §7.3.5: restore the persisted lease so leaseState survives restart.
|
|
311
|
-
const leaseJson = getMeta(this.#db, 'leaseState');
|
|
312
|
-
if (leaseJson !== undefined) {
|
|
313
|
-
this.#leaseState = JSON.parse(leaseJson);
|
|
314
|
-
}
|
|
315
|
-
// §7.4.2 trigger 1: the persisted local schema version differs from the
|
|
316
|
-
// generated version this client ships — run the wipe/re-bootstrap reset
|
|
317
|
-
// before the first sync round. A fresh install (no marker) is treated as
|
|
318
|
-
// already at the generated version.
|
|
319
|
-
this.#detectAndResetSchema();
|
|
320
|
-
// RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
|
|
321
|
-
// discarded at boot before anything can read it. The orphan/stale sweep is
|
|
322
|
-
// unconditional; the TTL is aware-binary hygiene only.
|
|
323
|
-
this.#reconcilePreviousVersionAtBoot();
|
|
324
|
-
// A registration remains app intent only while its table exists in the
|
|
325
|
-
// running schema. Removed-table registrations would poison every pull.
|
|
326
|
-
const subscriptions = pruneUnknownSubscriptions(this.#db, loadSubscriptions(this.#db), this.#schema);
|
|
413
|
+
throw classifySqliteFailure(error).error;
|
|
414
|
+
}
|
|
327
415
|
this.#started = true;
|
|
328
416
|
// A persisted active subscription needs one catch-up round on every open:
|
|
329
417
|
// realtime only covers changes after the socket connects, and an
|
|
@@ -359,12 +447,14 @@ export class SyncClient {
|
|
|
359
447
|
}
|
|
360
448
|
/**
|
|
361
449
|
* §7.4.1/§7.4.2: compare the generated schema version to the persisted
|
|
362
|
-
* marker and run the §7.4.3 reset when
|
|
450
|
+
* marker and run the §7.4.3 reset when it increases. Idempotent by the
|
|
363
451
|
* marker — a mid-reset crash re-runs the reset on the next boot.
|
|
452
|
+
*
|
|
453
|
+
* The startup phase's single transaction already pinned and validated the
|
|
454
|
+
* marker, so the shape chosen here commits under that snapshot.
|
|
364
455
|
*/
|
|
365
|
-
#detectAndResetSchema() {
|
|
366
|
-
|
|
367
|
-
if (markerJson === undefined) {
|
|
456
|
+
#detectAndResetSchema(marker) {
|
|
457
|
+
if (marker === undefined) {
|
|
368
458
|
// Fresh install: the tables just created match the running code.
|
|
369
459
|
this.#db.transaction(() => {
|
|
370
460
|
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
@@ -374,7 +464,6 @@ export class SyncClient {
|
|
|
374
464
|
});
|
|
375
465
|
return;
|
|
376
466
|
}
|
|
377
|
-
const marker = Number(markerJson);
|
|
378
467
|
if (marker === this.#schema.version) {
|
|
379
468
|
// Same-version opens remain self-healing for absent tables/indexes, and
|
|
380
469
|
// RFC 0005 D1 backfills the descriptor for a database first opened by
|
|
@@ -414,6 +503,7 @@ export class SyncClient {
|
|
|
414
503
|
this.#discardAcknowledgedRows();
|
|
415
504
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
416
505
|
resetSubscriptionsForBump(this.#db);
|
|
506
|
+
this.#replayOutbox();
|
|
417
507
|
setMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY, String(this.#schema.version));
|
|
418
508
|
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
419
509
|
saveSubscriptionScopeSchema(this.#db, this.#schema);
|
|
@@ -422,11 +512,10 @@ export class SyncClient {
|
|
|
422
512
|
for (const table of this.#schema.tables.values())
|
|
423
513
|
batch.table(table.name);
|
|
424
514
|
});
|
|
425
|
-
// The stop state is over: this client now ships a servable schema.
|
|
426
|
-
//
|
|
427
|
-
//
|
|
515
|
+
// The stop state is over: this client now ships a servable schema.
|
|
516
|
+
// The reset transaction already replayed pending intent before committing
|
|
517
|
+
// its marker and visible rows (§7.4.5).
|
|
428
518
|
this.#setSchemaFloor(undefined);
|
|
429
|
-
this.#replayOutbox();
|
|
430
519
|
}
|
|
431
520
|
/**
|
|
432
521
|
* RFC 0005 D5 steps 2-5: capture the previous-version context into its own
|
|
@@ -493,23 +582,33 @@ export class SyncClient {
|
|
|
493
582
|
return [];
|
|
494
583
|
}
|
|
495
584
|
const subscriptions = loadSubscriptions(this.#db);
|
|
496
|
-
const pending = listOutbox(this.#db);
|
|
497
585
|
this.#setUpgrading(true);
|
|
498
586
|
// RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
|
|
499
587
|
// orphan sweep but captures no new shadow.
|
|
500
588
|
this.#sweepPreviousVersionContainer();
|
|
589
|
+
const priorRejections = [...this.#rejections];
|
|
501
590
|
this.#applyBatch((batch) => {
|
|
502
591
|
this.#db.transaction(() => {
|
|
503
592
|
this.#discardAcknowledgedRows();
|
|
504
593
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
505
594
|
resetSubscriptionsForBump(this.#db);
|
|
506
595
|
setMeta(this.#db, LOG_EPOCH_META_KEY, logEpoch);
|
|
507
|
-
|
|
596
|
+
// §7.1/§7.4.4: recover codec-invalid persisted values in this reset
|
|
597
|
+
// transaction before they replay, so the reset boundary never fails on
|
|
598
|
+
// a commit an earlier version accepted.
|
|
599
|
+
this.#dropUnencodableOutbox();
|
|
600
|
+
for (const commit of listOutbox(this.#db)) {
|
|
508
601
|
this.#applyOperationsLocally(commit.operations, batch);
|
|
509
602
|
}
|
|
510
603
|
});
|
|
511
604
|
for (const table of this.#schema.tables.values())
|
|
512
605
|
batch.table(table.name);
|
|
606
|
+
}, undefined, (error) => {
|
|
607
|
+
// A failure after the recovery drop, including the reset's revision or
|
|
608
|
+
// COMMIT, rolls the database back: mirror that for the in-memory
|
|
609
|
+
// rejection cache so no dropped commit is published.
|
|
610
|
+
this.#rejections = priorRejections;
|
|
611
|
+
throw error;
|
|
513
612
|
});
|
|
514
613
|
this.#localResetEpoch += 1;
|
|
515
614
|
this.#setSyncNeeded(true);
|
|
@@ -706,23 +805,126 @@ export class SyncClient {
|
|
|
706
805
|
throw new ClientSyncError('client.authority_read_forbidden', 'authority read denied');
|
|
707
806
|
return this.#authorityReads.snapshot(this.#db, this.#schema);
|
|
708
807
|
}
|
|
808
|
+
querySnapshot(spec) {
|
|
809
|
+
const read = this.#readSnapshot({
|
|
810
|
+
statements: [
|
|
811
|
+
{
|
|
812
|
+
sql: spec.sql,
|
|
813
|
+
...(spec.params !== undefined ? { params: spec.params } : {}),
|
|
814
|
+
},
|
|
815
|
+
],
|
|
816
|
+
...(spec.coverage !== undefined ? { coverage: spec.coverage } : {}),
|
|
817
|
+
...(spec.owner !== undefined ? { owner: spec.owner } : {}),
|
|
818
|
+
});
|
|
819
|
+
const rows = read.queries[0];
|
|
820
|
+
if (read.queries.length !== 1 || rows === undefined) {
|
|
821
|
+
throw new ClientSyncError('client.failed', 'querySnapshot produced no statement result');
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
revision: read.revision,
|
|
825
|
+
rows,
|
|
826
|
+
coverage: read.coverage,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
snapshotRead(spec) {
|
|
830
|
+
return this.#readSnapshot(spec);
|
|
831
|
+
}
|
|
832
|
+
/** §7.5: one subscription's persisted catch-up state (never the owner cache). */
|
|
833
|
+
subscriptionCatchup(id) {
|
|
834
|
+
return this.#readSnapshot({ statements: [], subscriptions: [id] })
|
|
835
|
+
.subscriptions[0];
|
|
836
|
+
}
|
|
837
|
+
/** §7.5: one client commit's delivery status: pending, its retained outcome,
|
|
838
|
+
* or an explicit unknown (never held, or pruned by retention). */
|
|
839
|
+
commitDelivery(id) {
|
|
840
|
+
return this.#readSnapshot({ statements: [], commitIds: [id] })
|
|
841
|
+
.deliveries[0];
|
|
842
|
+
}
|
|
709
843
|
/**
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
844
|
+
* §7.5: the one atomic read primitive. Every public read (`querySnapshot`,
|
|
845
|
+
* `snapshotRead`, `subscriptionCatchup`, `commitDelivery`) enters here, so
|
|
846
|
+
* the read-only guard, transaction, revision, coverage, catch-up, delivery,
|
|
847
|
+
* and owner-failure observation exist once.
|
|
713
848
|
*/
|
|
714
|
-
|
|
849
|
+
#readSnapshot(request) {
|
|
715
850
|
this.#requireActive();
|
|
716
|
-
const owner =
|
|
717
|
-
|
|
851
|
+
const owner = request.owner;
|
|
852
|
+
const catchupOf = (id) => {
|
|
853
|
+
function corrupt() {
|
|
854
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted subscription state is invalid');
|
|
855
|
+
}
|
|
856
|
+
const row = this.#db.query(`SELECT tbl,status,cursor,bootstrap_state,effective_scopes,reason_code FROM ${SUBSCRIPTIONS_TABLE} WHERE id=?`, [id])[0];
|
|
857
|
+
if (row === undefined)
|
|
858
|
+
return { state: 'unknown', id };
|
|
859
|
+
const table = row.tbl;
|
|
860
|
+
const status = row.status;
|
|
861
|
+
const cursor = row.cursor;
|
|
862
|
+
if (typeof table !== 'string' ||
|
|
863
|
+
(status !== 'active' && status !== 'revoked' && status !== 'failed') ||
|
|
864
|
+
typeof cursor !== 'number' ||
|
|
865
|
+
!Number.isSafeInteger(cursor)) {
|
|
866
|
+
corrupt();
|
|
867
|
+
}
|
|
868
|
+
const token = row.bootstrap_state;
|
|
869
|
+
if (token !== null && typeof token !== 'string')
|
|
870
|
+
corrupt();
|
|
871
|
+
const rawScopes = row.effective_scopes;
|
|
872
|
+
let effectiveScopes;
|
|
873
|
+
if (rawScopes !== null) {
|
|
874
|
+
if (typeof rawScopes !== 'string')
|
|
875
|
+
corrupt();
|
|
876
|
+
try {
|
|
877
|
+
effectiveScopes = JSON.parse(rawScopes);
|
|
878
|
+
if (effectiveScopes === null)
|
|
879
|
+
effectiveScopes = undefined;
|
|
880
|
+
else if (typeof effectiveScopes !== 'object' ||
|
|
881
|
+
Array.isArray(effectiveScopes) ||
|
|
882
|
+
!Object.values(effectiveScopes).every((values) => Array.isArray(values) &&
|
|
883
|
+
values.every((value) => typeof value === 'string')))
|
|
884
|
+
corrupt();
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
corrupt();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const reasonCode = row.reason_code;
|
|
891
|
+
if (reasonCode !== null && typeof reasonCode !== 'string')
|
|
892
|
+
corrupt();
|
|
893
|
+
const active = status === 'active';
|
|
894
|
+
const hasResumeToken = token !== null;
|
|
895
|
+
return {
|
|
896
|
+
state: 'known',
|
|
897
|
+
id,
|
|
898
|
+
table,
|
|
899
|
+
status,
|
|
900
|
+
cursor,
|
|
901
|
+
hasResumeToken,
|
|
902
|
+
bootstrapComplete: active && cursor >= 0 && !hasResumeToken,
|
|
903
|
+
knownPendingPages: active && (hasResumeToken || cursor < 0),
|
|
904
|
+
...(effectiveScopes !== undefined ? { effectiveScopes } : {}),
|
|
905
|
+
...(reasonCode !== null ? { reasonCode } : {}),
|
|
906
|
+
};
|
|
907
|
+
};
|
|
908
|
+
const deliveryOf = (id) => {
|
|
909
|
+
const pending = this.#db.query(`SELECT 1 FROM ${OUTBOX_TABLE} WHERE client_commit_id=? LIMIT 1`, [id]).length > 0;
|
|
910
|
+
if (pending)
|
|
911
|
+
return { status: 'pending', clientCommitId: id };
|
|
912
|
+
const outcome = persistedCommitOutcome(this.#db, id);
|
|
913
|
+
return outcome === undefined
|
|
914
|
+
? { status: 'unknown', clientCommitId: id }
|
|
915
|
+
: { status: 'known', clientCommitId: id, outcome };
|
|
916
|
+
};
|
|
917
|
+
let read;
|
|
718
918
|
try {
|
|
719
|
-
|
|
720
|
-
|
|
919
|
+
for (const statement of request.statements) {
|
|
920
|
+
assertReadOnlyQuery(statement.sql);
|
|
921
|
+
}
|
|
922
|
+
read = this.#db.transaction(() => {
|
|
721
923
|
const revision = getLocalRevision(this.#db);
|
|
722
|
-
const
|
|
924
|
+
const queries = request.statements.map((statement) => stripSyncColumns(this.#db.query(statement.sql, statement.params)));
|
|
723
925
|
const pending = [];
|
|
724
926
|
const missing = [];
|
|
725
|
-
for (const requested of
|
|
927
|
+
for (const requested of request.coverage ?? []) {
|
|
726
928
|
const baseKey = windowBaseKey(requested.base);
|
|
727
929
|
const state = this.windowState(requested.base);
|
|
728
930
|
for (const unit of new Set(requested.units)) {
|
|
@@ -735,20 +937,26 @@ export class SyncClient {
|
|
|
735
937
|
}
|
|
736
938
|
return {
|
|
737
939
|
revision,
|
|
738
|
-
|
|
940
|
+
queries,
|
|
739
941
|
coverage: {
|
|
740
942
|
complete: pending.length === 0 && missing.length === 0,
|
|
741
943
|
pending,
|
|
742
944
|
missing,
|
|
743
945
|
},
|
|
946
|
+
subscriptions: (request.subscriptions ?? []).map(catchupOf),
|
|
947
|
+
deliveries: (request.commitIds ?? []).map(deliveryOf),
|
|
744
948
|
};
|
|
745
949
|
});
|
|
746
950
|
}
|
|
747
951
|
catch (error) {
|
|
748
952
|
const failure = classifySqliteFailure(error);
|
|
749
|
-
const sqliteCode = failure.sqliteCode;
|
|
750
|
-
const code = failure.code ?? 'client.query_failed';
|
|
751
953
|
if (owner !== undefined) {
|
|
954
|
+
const code = failure.code ??
|
|
955
|
+
(error instanceof ClientSyncError &&
|
|
956
|
+
error.code === 'sync.local_corrupt'
|
|
957
|
+
? 'sync.local_corrupt'
|
|
958
|
+
: 'client.query_failed');
|
|
959
|
+
const sqliteCode = failure.sqliteCode;
|
|
752
960
|
const tables = [...new Set(owner.tables)].sort();
|
|
753
961
|
const previous = this.#queryFailures.get(owner.id);
|
|
754
962
|
if (previous === undefined ||
|
|
@@ -760,7 +968,7 @@ export class SyncClient {
|
|
|
760
968
|
id: owner.id,
|
|
761
969
|
tables,
|
|
762
970
|
code,
|
|
763
|
-
sqliteCode
|
|
971
|
+
sqliteCode,
|
|
764
972
|
atMs: this.#now(),
|
|
765
973
|
}));
|
|
766
974
|
for (const id of this.#queryFailures.keys()) {
|
|
@@ -776,7 +984,7 @@ export class SyncClient {
|
|
|
776
984
|
if (owner !== undefined && this.#queryFailures.delete(owner.id)) {
|
|
777
985
|
this.#emitDiagnostics();
|
|
778
986
|
}
|
|
779
|
-
return
|
|
987
|
+
return read;
|
|
780
988
|
}
|
|
781
989
|
// -- previous-version context (RFC 0005) ----------------------------------
|
|
782
990
|
/**
|
|
@@ -810,6 +1018,10 @@ export class SyncClient {
|
|
|
810
1018
|
const refusal = storedPreviousVersionRefusal(this.#db);
|
|
811
1019
|
return this.#previousVersionUnavailable(currentVersion, refusal?.reason ?? 'no-previous-descriptor');
|
|
812
1020
|
}
|
|
1021
|
+
if (record.currentVersion !== currentVersion) {
|
|
1022
|
+
this.#discardPreviousVersion();
|
|
1023
|
+
return this.#previousVersionUnavailable(currentVersion, 'no-previous-descriptor');
|
|
1024
|
+
}
|
|
813
1025
|
if (nowMs - record.createdAtMs >
|
|
814
1026
|
previousVersionMaxAgeMs(this.#config.previousVersionContext)) {
|
|
815
1027
|
this.#discardPreviousVersion();
|
|
@@ -1905,6 +2117,16 @@ export class SyncClient {
|
|
|
1905
2117
|
});
|
|
1906
2118
|
}
|
|
1907
2119
|
const pkValue = requireRowId(json[table.primaryKey]);
|
|
2120
|
+
// §7.1: declared-type validation runs before the commit is recorded, so a
|
|
2121
|
+
// refused value leaves no outbox entry, no optimistic row, no revision
|
|
2122
|
+
// and no observation event.
|
|
2123
|
+
const refusal = this.#operationValuesError(table, json);
|
|
2124
|
+
if (refusal !== undefined)
|
|
2125
|
+
// §7.5: the same structured shape the native authoring APIs return — a
|
|
2126
|
+
// static message, and the dynamic cause in `details.legacyCause`.
|
|
2127
|
+
throw new ClientSyncError('sync.invalid_request', 'the authoring request is invalid', false, {
|
|
2128
|
+
legacyCause: `sync.invalid_request: table ${table.name}: ${refusal}`,
|
|
2129
|
+
});
|
|
1908
2130
|
return definedObject({
|
|
1909
2131
|
table: mutation.table,
|
|
1910
2132
|
rowId: pkValue,
|
|
@@ -2257,61 +2479,123 @@ export class SyncClient {
|
|
|
2257
2479
|
* incompatible`); its purely-optimistic rows are undone (§7.2). Returns
|
|
2258
2480
|
* the encoded push frames index-aligned with the surviving `outbox`.
|
|
2259
2481
|
*/
|
|
2260
|
-
async #encodeOutboxForPush() {
|
|
2482
|
+
async #encodeOutboxForPush(header, pull, byteCap) {
|
|
2261
2483
|
// Pin before the first encryption await: mutations can append while a
|
|
2262
2484
|
// round is encoding, and belong to the next request.
|
|
2263
2485
|
const bounds = this.#db.query(`SELECT COUNT(*)AS count,MAX(seq)AS last_seq FROM ${OUTBOX_TABLE}`)[0];
|
|
2264
2486
|
const pendingCount = bounds.count;
|
|
2265
2487
|
const throughSeq = bounds.last_seq ?? 0;
|
|
2488
|
+
const maxCommits = this.#config.limits?.maxPushCommitsPerRequest;
|
|
2489
|
+
const maxOps = this.#config.limits?.maxPushOperationsPerRequest ?? MAX_OPS_PER_REQUEST;
|
|
2490
|
+
// A request must start with REQ_HEADER and carry PULL_HEADER; those two
|
|
2491
|
+
// frames make a valid probe whose encoded size cancels out of the
|
|
2492
|
+
// per-commit delta measured against the canonical encoder.
|
|
2493
|
+
const baseBytes = byteCap === undefined
|
|
2494
|
+
? 0
|
|
2495
|
+
: encodeMessage({
|
|
2496
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
2497
|
+
msgKind: 'request',
|
|
2498
|
+
frames: [header, pull],
|
|
2499
|
+
}).length;
|
|
2266
2500
|
const pushFrames = [];
|
|
2267
2501
|
const outbox = [];
|
|
2502
|
+
const frameBytes = [];
|
|
2268
2503
|
let deferred = 0;
|
|
2269
2504
|
let ops = 0;
|
|
2505
|
+
let bytes = 0;
|
|
2270
2506
|
let processed = 0;
|
|
2271
2507
|
for (const commit of iterateOutbox(this.#db, throughSeq)) {
|
|
2272
|
-
// §6.1
|
|
2273
|
-
//
|
|
2274
|
-
//
|
|
2275
|
-
|
|
2276
|
-
if (outbox.length > 0 &&
|
|
2277
|
-
ops + commit.operations.length > MAX_OPS_PER_REQUEST) {
|
|
2508
|
+
// §6.1/§7.1: the commit-count and operation caps are hard. A first
|
|
2509
|
+
// commit that alone exceeds the operation cap is a typed capacity error
|
|
2510
|
+
// and stays queued; a later one defers with the complete suffix.
|
|
2511
|
+
if (maxCommits !== undefined && pushFrames.length >= maxCommits) {
|
|
2278
2512
|
deferred = pendingCount - processed;
|
|
2279
2513
|
break;
|
|
2280
2514
|
}
|
|
2281
|
-
|
|
2515
|
+
let pushFrame;
|
|
2282
2516
|
try {
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
outbox.push(commit);
|
|
2300
|
-
ops += commit.operations.length;
|
|
2517
|
+
pushFrame =
|
|
2518
|
+
// §5.11: encrypted columns are encrypted at this encode-at-send
|
|
2519
|
+
// seam before the row codec serializes them. The stored local row
|
|
2520
|
+
// supplies an ABSENT key-id selector (§6.1 presence set first,
|
|
2521
|
+
// stored row second); an unresolvable key becomes a durable
|
|
2522
|
+
// rejection for that commit, never a sync() throw.
|
|
2523
|
+
await encodeOutboxCommit(this.#schema, commit, this.#encryption, (table, rowId) => {
|
|
2524
|
+
const selector = this.#encryption?.keyIdColumns?.[table.name];
|
|
2525
|
+
if (selector === undefined)
|
|
2526
|
+
return undefined;
|
|
2527
|
+
const row = this.#db.query(`SELECT ${quoteIdent(selector)} AS value FROM ${quoteIdent(table.name)} WHERE ${quoteIdent(table.primaryKey)}=?`, [rowId])[0];
|
|
2528
|
+
const value = row?.value;
|
|
2529
|
+
return typeof value === 'string' && value.length > 0
|
|
2530
|
+
? value
|
|
2531
|
+
: undefined;
|
|
2532
|
+
});
|
|
2301
2533
|
}
|
|
2302
2534
|
catch (error) {
|
|
2303
2535
|
if (error instanceof OutboxEncodeError) {
|
|
2304
2536
|
this.#dropIncompatibleCommit(commit, error.message);
|
|
2537
|
+
processed += 1;
|
|
2305
2538
|
continue;
|
|
2306
2539
|
}
|
|
2307
2540
|
if (error instanceof EncryptError) {
|
|
2308
2541
|
this.#dropIncompatibleCommit(commit, error.message, error.code);
|
|
2542
|
+
processed += 1;
|
|
2543
|
+
continue;
|
|
2544
|
+
}
|
|
2545
|
+
// §7.1/§7.4.4: a persisted value the current codec refuses (an earlier
|
|
2546
|
+
// version accepted it) is an incompatible commit, never a wedged queue.
|
|
2547
|
+
if (error instanceof EncodeError ||
|
|
2548
|
+
(error instanceof ClientSyncError &&
|
|
2549
|
+
error.code === 'sync.invalid_request')) {
|
|
2550
|
+
this.#dropIncompatibleCommit(commit, LEGACY_VALUE_REFUSAL_MESSAGE, OUTBOX_INCOMPATIBLE_CODE, true, INVALID_STORED_VALUES_REASON);
|
|
2551
|
+
processed += 1;
|
|
2309
2552
|
continue;
|
|
2310
2553
|
}
|
|
2311
2554
|
throw error;
|
|
2312
2555
|
}
|
|
2556
|
+
// The operation cap is checked after the encode/drop so an unencodable
|
|
2557
|
+
// head is dropped as a durable rejection first, matching the native
|
|
2558
|
+
// prepass priority.
|
|
2559
|
+
if (ops + commit.operations.length > maxOps) {
|
|
2560
|
+
if (pushFrames.length === 0) {
|
|
2561
|
+
throw this.#pushCapacityError('operations', maxOps, commit.operations.length, commit.clientCommitId);
|
|
2562
|
+
}
|
|
2563
|
+
deferred = pendingCount - processed;
|
|
2564
|
+
break;
|
|
2565
|
+
}
|
|
2566
|
+
const frame = byteCap === undefined
|
|
2567
|
+
? 0
|
|
2568
|
+
: encodeMessage({
|
|
2569
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
2570
|
+
msgKind: 'request',
|
|
2571
|
+
frames: [header, pushFrame, pull],
|
|
2572
|
+
}).length - baseBytes;
|
|
2573
|
+
// The head is always encoded so the caller can report the full
|
|
2574
|
+
// projected request size with the final header and subscription bytes;
|
|
2575
|
+
// a later commit defers once it no longer fits.
|
|
2576
|
+
if (byteCap !== undefined &&
|
|
2577
|
+
pushFrames.length > 0 &&
|
|
2578
|
+
bytes + frame > byteCap) {
|
|
2579
|
+
deferred = pendingCount - processed;
|
|
2580
|
+
break;
|
|
2581
|
+
}
|
|
2582
|
+
pushFrames.push(pushFrame);
|
|
2583
|
+
outbox.push(commit);
|
|
2584
|
+
frameBytes.push(frame);
|
|
2585
|
+
ops += commit.operations.length;
|
|
2586
|
+
bytes += frame;
|
|
2587
|
+
processed += 1;
|
|
2313
2588
|
}
|
|
2314
|
-
return { pushFrames, outbox, deferred };
|
|
2589
|
+
return { pushFrames, outbox, frameBytes, deferred };
|
|
2590
|
+
}
|
|
2591
|
+
/** §7.1: a configured push capacity limit cannot carry this request. */
|
|
2592
|
+
#pushCapacityError(kind, limit, size, clientCommitId) {
|
|
2593
|
+
return new ClientSyncError('client.push_request_too_large', 'the push request exceeds a configured capacity limit', false, definedObject({
|
|
2594
|
+
kind,
|
|
2595
|
+
limit,
|
|
2596
|
+
size,
|
|
2597
|
+
...(clientCommitId !== undefined ? { clientCommitId } : {}),
|
|
2598
|
+
}));
|
|
2315
2599
|
}
|
|
2316
2600
|
/**
|
|
2317
2601
|
* §7.4.4: drop a commit that cannot re-encode after a bump, mirroring the
|
|
@@ -2319,19 +2603,27 @@ export class SyncClient {
|
|
|
2319
2603
|
* purely-optimistic rows are undone, and a rejection record is raised.
|
|
2320
2604
|
* §5.11: the same drop carries an encode-seam `client.encrypt_failed`.
|
|
2321
2605
|
*/
|
|
2322
|
-
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE) {
|
|
2606
|
+
#dropIncompatibleCommit(commit, message, code = OUTBOX_INCOMPATIBLE_CODE, replay = true, reason, classifiedFailures) {
|
|
2607
|
+
const priorRejections = [...this.#rejections];
|
|
2323
2608
|
this.#applyBatch((batch) => {
|
|
2324
2609
|
if (this.#retainFailedCommits && code === 'client.encrypt_failed')
|
|
2325
2610
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
2326
|
-
this.#rollbackFailedCommit(commit, batch);
|
|
2327
|
-
|
|
2611
|
+
this.#rollbackFailedCommit(commit, batch, classifiedFailures);
|
|
2612
|
+
if (replay)
|
|
2613
|
+
this.#replayOutbox();
|
|
2614
|
+
// §7.2/§7.4.4: the journal holds the operation envelope only when its
|
|
2615
|
+
// stored shape is representable. A dropped legacy commit whose envelope
|
|
2616
|
+
// the journal cannot carry keeps its durable commitId, code and a bounded
|
|
2617
|
+
// `details.reason` instead of an opaque rewritten value.
|
|
2618
|
+
const representable = commit.operations.every(isStoredOperation);
|
|
2328
2619
|
const rejection = definedObject({
|
|
2329
2620
|
clientCommitId: commit.clientCommitId,
|
|
2330
2621
|
opIndex: 0,
|
|
2331
2622
|
code,
|
|
2332
2623
|
message,
|
|
2333
2624
|
retryable: false,
|
|
2334
|
-
operation: commit.operations[0],
|
|
2625
|
+
...(representable ? { operation: commit.operations[0] } : {}),
|
|
2626
|
+
...(reason === undefined ? {} : { details: { reason } }),
|
|
2335
2627
|
});
|
|
2336
2628
|
this.#rejections.push(rejection);
|
|
2337
2629
|
recordCommitOutcome(this.#db, {
|
|
@@ -2339,12 +2631,17 @@ export class SyncClient {
|
|
|
2339
2631
|
status: 'rejected',
|
|
2340
2632
|
recordedAtMs: this.#now(),
|
|
2341
2633
|
results: [{ status: 'error', rejection }],
|
|
2342
|
-
operations: commit.operations,
|
|
2634
|
+
...(representable ? { operations: commit.operations } : {}),
|
|
2343
2635
|
});
|
|
2344
2636
|
pruneCommitOutcomes(this.#db, this.#outcomeRetentionMaxEntries);
|
|
2345
2637
|
batch.status();
|
|
2346
2638
|
batch.rejections();
|
|
2347
2639
|
batch.outcomes();
|
|
2640
|
+
}, undefined, (error) => {
|
|
2641
|
+
// The drop's own outcome write or publication failed: mirror the
|
|
2642
|
+
// rollback so no rejection the journal did not keep stays in memory.
|
|
2643
|
+
this.#rejections = priorRejections;
|
|
2644
|
+
throw error;
|
|
2348
2645
|
});
|
|
2349
2646
|
}
|
|
2350
2647
|
// -- sync -------------------------------------------------------------------
|
|
@@ -2461,6 +2758,29 @@ export class SyncClient {
|
|
|
2461
2758
|
try {
|
|
2462
2759
|
await this.#drainPendingEvictions();
|
|
2463
2760
|
const logEpoch = getMeta(this.#db, LOG_EPOCH_META_KEY);
|
|
2761
|
+
// §7.4.4: encode the outbox with the CURRENT codec; a commit that
|
|
2762
|
+
// cannot express itself under the new schema (a dropped column/table)
|
|
2763
|
+
// is removed from the push and surfaced as a rejection, never wedging
|
|
2764
|
+
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
2765
|
+
// mapping.
|
|
2766
|
+
const limits = this.#config.limits;
|
|
2767
|
+
const byteCap = limits?.maxPushRequestBytes;
|
|
2768
|
+
const header = definedObject({
|
|
2769
|
+
type: 'REQ_HEADER',
|
|
2770
|
+
clientId: this.#clientId,
|
|
2771
|
+
schemaVersion: this.#schema.version,
|
|
2772
|
+
logEpoch: logEpoch,
|
|
2773
|
+
});
|
|
2774
|
+
const pull = {
|
|
2775
|
+
type: 'PULL_HEADER',
|
|
2776
|
+
limitCommits: limits?.limitCommits ?? 0,
|
|
2777
|
+
limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
|
|
2778
|
+
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
2779
|
+
accept: this.#acceptMask(),
|
|
2780
|
+
};
|
|
2781
|
+
const { pushFrames, outbox, frameBytes, deferred: encodedDeferred, } = logEpoch === undefined
|
|
2782
|
+
? { pushFrames: [], outbox: [], frameBytes: [], deferred: 0 }
|
|
2783
|
+
: await this.#encodeOutboxForPush(header, pull, byteCap);
|
|
2464
2784
|
// §5.9.7 B4: upload pending blobs BEFORE pushing rows that reference
|
|
2465
2785
|
// them, so the server-side existence check (§6.6) passes.
|
|
2466
2786
|
if (logEpoch !== undefined &&
|
|
@@ -2468,34 +2788,14 @@ export class SyncClient {
|
|
|
2468
2788
|
this.#config.blobs !== undefined) {
|
|
2469
2789
|
await this.#flushBlobUploads();
|
|
2470
2790
|
}
|
|
2471
|
-
//
|
|
2472
|
-
//
|
|
2473
|
-
//
|
|
2474
|
-
// the queue. `pushFrames` and `outbox` stay index-aligned for result
|
|
2475
|
-
// mapping.
|
|
2476
|
-
const { pushFrames, outbox, deferred } = logEpoch === undefined
|
|
2477
|
-
? { pushFrames: [], outbox: [], deferred: 0 }
|
|
2478
|
-
: await this.#encodeOutboxForPush();
|
|
2479
|
-
// Captured together with the subscription state below: the response
|
|
2480
|
-
// apply persists SUB_END cursors only while this epoch is current.
|
|
2791
|
+
// Captured after encoding: a subscribe/unsubscribe during the
|
|
2792
|
+
// encryption await must not make the final request exceed the byte
|
|
2793
|
+
// budget, and the reset epoch must be the one the response apply checks.
|
|
2481
2794
|
const resetEpoch = this.#localResetEpoch;
|
|
2482
2795
|
const subs = loadSubscriptions(this.#db).filter((sub) => sub.status === 'active');
|
|
2483
|
-
const
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
type: 'REQ_HEADER',
|
|
2487
|
-
clientId: this.#clientId,
|
|
2488
|
-
schemaVersion: this.#schema.version,
|
|
2489
|
-
logEpoch: logEpoch,
|
|
2490
|
-
}),
|
|
2491
|
-
...pushFrames,
|
|
2492
|
-
{
|
|
2493
|
-
type: 'PULL_HEADER',
|
|
2494
|
-
limitCommits: limits?.limitCommits ?? 0,
|
|
2495
|
-
limitSnapshotRows: limits?.limitSnapshotRows ?? 0,
|
|
2496
|
-
maxSnapshotPages: limits?.maxSnapshotPages ?? 0,
|
|
2497
|
-
accept: this.#acceptMask(),
|
|
2498
|
-
},
|
|
2796
|
+
const fixedFrames = [
|
|
2797
|
+
header,
|
|
2798
|
+
pull,
|
|
2499
2799
|
...subs.map((sub) => definedObject({
|
|
2500
2800
|
type: 'SUBSCRIPTION',
|
|
2501
2801
|
id: sub.id,
|
|
@@ -2506,6 +2806,38 @@ export class SyncClient {
|
|
|
2506
2806
|
bootstrapState: sub.bootstrapState,
|
|
2507
2807
|
})),
|
|
2508
2808
|
];
|
|
2809
|
+
let admitted = pushFrames.length;
|
|
2810
|
+
let deferred = encodedDeferred;
|
|
2811
|
+
if (byteCap !== undefined) {
|
|
2812
|
+
// The fixed frames encode as envelope + fixed frames + END; each
|
|
2813
|
+
// admitted push frame adds its own (5 + payload). Trimming the tail
|
|
2814
|
+
// re-fits the request to the final subscription state.
|
|
2815
|
+
const fixedBytes = encodeMessage({
|
|
2816
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
2817
|
+
msgKind: 'request',
|
|
2818
|
+
frames: fixedFrames,
|
|
2819
|
+
}).length;
|
|
2820
|
+
if (fixedBytes > byteCap)
|
|
2821
|
+
throw this.#pushCapacityError('bytes', byteCap, fixedBytes);
|
|
2822
|
+
let admittedBytes = 0;
|
|
2823
|
+
for (const size of frameBytes)
|
|
2824
|
+
admittedBytes += size;
|
|
2825
|
+
while (admitted > 0 && fixedBytes + admittedBytes > byteCap) {
|
|
2826
|
+
admitted -= 1;
|
|
2827
|
+
admittedBytes -= frameBytes[admitted];
|
|
2828
|
+
deferred += 1;
|
|
2829
|
+
}
|
|
2830
|
+
if (admitted === 0 && pushFrames.length > 0) {
|
|
2831
|
+
throw this.#pushCapacityError('bytes', byteCap, fixedBytes + frameBytes[0], outbox[0].clientCommitId);
|
|
2832
|
+
}
|
|
2833
|
+
pushFrames.length = admitted;
|
|
2834
|
+
outbox.length = admitted;
|
|
2835
|
+
}
|
|
2836
|
+
const frames = [
|
|
2837
|
+
fixedFrames[0],
|
|
2838
|
+
...pushFrames,
|
|
2839
|
+
...fixedFrames.slice(1),
|
|
2840
|
+
];
|
|
2509
2841
|
const requestBytes = encodeMessage({
|
|
2510
2842
|
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
2511
2843
|
msgKind: 'request',
|
|
@@ -2577,13 +2909,68 @@ export class SyncClient {
|
|
|
2577
2909
|
* pages pending, no resets to recover (§4.5 "pull again" SHOULD).
|
|
2578
2910
|
*/
|
|
2579
2911
|
async syncUntilIdle(maxRounds) {
|
|
2912
|
+
// §7.7: an explicit budget is a strict positive round count. Zero,
|
|
2913
|
+
// fractional, and out-of-range values are caller errors.
|
|
2914
|
+
if (maxRounds !== undefined &&
|
|
2915
|
+
(!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 0xffffffff))
|
|
2916
|
+
throw invalidRequest('maxRounds must be a positive integer');
|
|
2580
2917
|
const budget = maxRounds ?? 20;
|
|
2581
|
-
|
|
2918
|
+
const aggregate = {
|
|
2919
|
+
pushed: 0,
|
|
2920
|
+
applied: [],
|
|
2921
|
+
rejected: [],
|
|
2922
|
+
retryable: [],
|
|
2923
|
+
conflicts: [],
|
|
2924
|
+
commitsApplied: 0,
|
|
2925
|
+
segmentRowsApplied: 0,
|
|
2926
|
+
bootstrapping: [],
|
|
2927
|
+
resets: [],
|
|
2928
|
+
revoked: [],
|
|
2929
|
+
failed: [],
|
|
2930
|
+
deferredCommits: 0,
|
|
2931
|
+
schemaFloor: undefined,
|
|
2932
|
+
};
|
|
2933
|
+
const result = (budgetExhausted) => ({
|
|
2934
|
+
pushed: aggregate.pushed,
|
|
2935
|
+
applied: aggregate.applied,
|
|
2936
|
+
rejected: aggregate.rejected,
|
|
2937
|
+
retryable: aggregate.retryable,
|
|
2938
|
+
conflicts: aggregate.conflicts,
|
|
2939
|
+
commitsApplied: aggregate.commitsApplied,
|
|
2940
|
+
segmentRowsApplied: aggregate.segmentRowsApplied,
|
|
2941
|
+
bootstrapping: aggregate.bootstrapping,
|
|
2942
|
+
resets: aggregate.resets,
|
|
2943
|
+
revoked: aggregate.revoked,
|
|
2944
|
+
failed: aggregate.failed,
|
|
2945
|
+
...(aggregate.deferredCommits > 0
|
|
2946
|
+
? { deferredCommits: aggregate.deferredCommits }
|
|
2947
|
+
: {}),
|
|
2948
|
+
...(aggregate.schemaFloor !== undefined
|
|
2949
|
+
? { schemaFloor: aggregate.schemaFloor }
|
|
2950
|
+
: {}),
|
|
2951
|
+
budgetExhausted,
|
|
2952
|
+
});
|
|
2582
2953
|
for (let round = 0; round < budget; round++) {
|
|
2583
2954
|
const before = JSON.stringify(loadSubscriptions(this.#db).map((sub) => [sub.id, sub.bootstrapState]));
|
|
2584
|
-
last = await this.sync();
|
|
2955
|
+
const last = await this.sync();
|
|
2956
|
+
// Historical counters accumulate; current readiness (`bootstrapping`,
|
|
2957
|
+
// `deferredCommits`, `schemaFloor`) describes the latest round.
|
|
2958
|
+
aggregate.pushed += last.pushed;
|
|
2959
|
+
aggregate.applied.push(...last.applied);
|
|
2960
|
+
aggregate.rejected.push(...last.rejected);
|
|
2961
|
+
aggregate.retryable.push(...last.retryable);
|
|
2962
|
+
aggregate.conflicts.push(...last.conflicts);
|
|
2963
|
+
aggregate.commitsApplied += last.commitsApplied;
|
|
2964
|
+
aggregate.segmentRowsApplied += last.segmentRowsApplied;
|
|
2965
|
+
aggregate.bootstrapping = [...last.bootstrapping];
|
|
2966
|
+
aggregate.resets.push(...last.resets);
|
|
2967
|
+
aggregate.revoked.push(...last.revoked);
|
|
2968
|
+
aggregate.failed.push(...last.failed);
|
|
2969
|
+
aggregate.deferredCommits = last.deferredCommits ?? 0;
|
|
2970
|
+
aggregate.schemaFloor = last.schemaFloor;
|
|
2971
|
+
// A disabled transport or a schema floor ends the loop, not the budget.
|
|
2585
2972
|
if (!this.#transportEnabled || last.schemaFloor !== undefined)
|
|
2586
|
-
return
|
|
2973
|
+
return result(false);
|
|
2587
2974
|
if (maxRounds === undefined &&
|
|
2588
2975
|
last.segmentRowsApplied > 0 &&
|
|
2589
2976
|
last.bootstrapping.length > 0 &&
|
|
@@ -2599,10 +2986,10 @@ export class SyncClient {
|
|
|
2599
2986
|
last.resets.length === 0 &&
|
|
2600
2987
|
(last.deferredCommits ?? 0) === 0 &&
|
|
2601
2988
|
!this.#needsPull) {
|
|
2602
|
-
return
|
|
2989
|
+
return result(false);
|
|
2603
2990
|
}
|
|
2604
2991
|
}
|
|
2605
|
-
|
|
2992
|
+
return result(true);
|
|
2606
2993
|
}
|
|
2607
2994
|
/**
|
|
2608
2995
|
* One request/response round trip (§8.7): over the socket whenever it
|
|
@@ -2629,12 +3016,12 @@ export class SyncClient {
|
|
|
2629
3016
|
try {
|
|
2630
3017
|
socket.sendBytes(tagged);
|
|
2631
3018
|
}
|
|
2632
|
-
catch
|
|
3019
|
+
catch {
|
|
2633
3020
|
this.#pendingRound = undefined;
|
|
2634
3021
|
// The socket could not carry the round: record the same loss the
|
|
2635
3022
|
// Rust core records when its realtime round call fails (§8.8).
|
|
2636
3023
|
this.#setRealtimeState('lost', 'sync.transport_failed');
|
|
2637
|
-
reject(new ClientSyncError('sync.transport_failed',
|
|
3024
|
+
reject(new ClientSyncError('sync.transport_failed', 'socket round send failed', true, { causeKind: 'unknown' }));
|
|
2638
3025
|
}
|
|
2639
3026
|
});
|
|
2640
3027
|
}
|
|
@@ -2650,7 +3037,7 @@ export class SyncClient {
|
|
|
2650
3037
|
typeof error?.code === 'string') {
|
|
2651
3038
|
throw error;
|
|
2652
3039
|
}
|
|
2653
|
-
throw new ClientSyncError('sync.transport_failed',
|
|
3040
|
+
throw new ClientSyncError('sync.transport_failed', 'transport round failed', true, { causeKind: 'unknown' });
|
|
2654
3041
|
});
|
|
2655
3042
|
}
|
|
2656
3043
|
/** Abort the in-flight socket round (socket closed or disconnected). */
|
|
@@ -2795,9 +3182,9 @@ export class SyncClient {
|
|
|
2795
3182
|
try {
|
|
2796
3183
|
done = round.scanner.push(body);
|
|
2797
3184
|
}
|
|
2798
|
-
catch
|
|
3185
|
+
catch {
|
|
2799
3186
|
this.#pendingRound = undefined;
|
|
2800
|
-
round.reject(invalidRequest(
|
|
3187
|
+
round.reject(invalidRequest('malformed round response stream (§8.7)'));
|
|
2801
3188
|
return;
|
|
2802
3189
|
}
|
|
2803
3190
|
if (done === undefined)
|
|
@@ -3279,9 +3666,20 @@ export class SyncClient {
|
|
|
3279
3666
|
if (this.#retainFailedCommits)
|
|
3280
3667
|
retainFailedRows(this.#db, commit, listOutboxBeforeImages(this.#db, commit.clientCommitId));
|
|
3281
3668
|
this.#rollbackFailedCommit(commit, batch);
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3669
|
+
// §7.2: rebasing a rejection reaches rows the same-row replay inside
|
|
3670
|
+
// `#rollbackFailedCommit` cannot: peers of a secondary UNIQUE index
|
|
3671
|
+
// (table-wide scope) and later commits over legacy before-images.
|
|
3672
|
+
// ACK-only groups reconcile nothing and stay zero-work.
|
|
3673
|
+
this.#replayOutbox(commit.operations.flatMap((operation) => {
|
|
3674
|
+
const table = this.#schema.tables.get(operation.table);
|
|
3675
|
+
return table === undefined
|
|
3676
|
+
? []
|
|
3677
|
+
: [
|
|
3678
|
+
JSON.stringify(hasUniqueIndex(table)
|
|
3679
|
+
? [operation.table]
|
|
3680
|
+
: [operation.table, operation.rowId]),
|
|
3681
|
+
];
|
|
3682
|
+
}));
|
|
3285
3683
|
});
|
|
3286
3684
|
batch.status();
|
|
3287
3685
|
summary.rejected.push(frame.clientCommitId);
|
|
@@ -3697,7 +4095,7 @@ export class SyncClient {
|
|
|
3697
4095
|
}
|
|
3698
4096
|
}
|
|
3699
4097
|
}
|
|
3700
|
-
#rollbackFailedCommit(commit, batch) {
|
|
4098
|
+
#rollbackFailedCommit(commit, batch, classifiedFailures) {
|
|
3701
4099
|
const images = listOutboxBeforeImages(this.#db, commit.clientCommitId);
|
|
3702
4100
|
const imageByIndex = new Map(images.map((image) => [image.opIndex, image]));
|
|
3703
4101
|
const complete = commit.operations.every((_, index) => imageByIndex.has(index));
|
|
@@ -3729,12 +4127,28 @@ export class SyncClient {
|
|
|
3729
4127
|
if (affectedOperations.length === 0)
|
|
3730
4128
|
continue;
|
|
3731
4129
|
replaceOutboxBeforeImages(this.#db, later.clientCommitId, this.#captureBeforeImages(later.operations, affectedKeys));
|
|
4130
|
+
// §7.4.4: a later commit the recovery already classified refused never
|
|
4131
|
+
// replays. Its before-image is still rebased above, so dropping it later
|
|
4132
|
+
// restores the same base this rollback just restored.
|
|
4133
|
+
if (classifiedFailures?.has(later.clientCommitId))
|
|
4134
|
+
continue;
|
|
3732
4135
|
this.#applyOperationsLocally(affectedOperations, batch);
|
|
3733
4136
|
}
|
|
3734
4137
|
}
|
|
3735
4138
|
#applyOperationsLocally(operations, batch, replay = false) {
|
|
3736
4139
|
for (const op of operations) {
|
|
3737
|
-
|
|
4140
|
+
// §7.4.4: the overlay replay is schema-agnostic (§0), so an operation
|
|
4141
|
+
// whose table the current schema removed has no local mirror to replay
|
|
4142
|
+
// into and is skipped here. A removed-table upsert stays durable for the
|
|
4143
|
+
// send-time `sync.outbox_incompatible` classification; a value-free
|
|
4144
|
+
// delete carries no values to classify and keeps ordinary server
|
|
4145
|
+
// validation. Every other call runs against the live schema and keeps the
|
|
4146
|
+
// loud `sync.unknown_table`.
|
|
4147
|
+
const table = replay
|
|
4148
|
+
? this.#schema.tables.get(op.table)
|
|
4149
|
+
: this.#table(op.table);
|
|
4150
|
+
if (table === undefined)
|
|
4151
|
+
continue;
|
|
3738
4152
|
let precise = batch === undefined
|
|
3739
4153
|
? false
|
|
3740
4154
|
: this.#recordStoredRowScopes(batch, table, op.rowId);
|
|
@@ -3762,8 +4176,23 @@ export class SyncClient {
|
|
|
3762
4176
|
return jsonToRowValue(value);
|
|
3763
4177
|
return local === undefined
|
|
3764
4178
|
? null
|
|
3765
|
-
:
|
|
4179
|
+
: replay
|
|
4180
|
+
? coerceSqlRepresentation(column, local[column.name] ?? null)
|
|
4181
|
+
: fromSqlValue(column, local[column.name] ?? null);
|
|
3766
4182
|
});
|
|
4183
|
+
if (replay) {
|
|
4184
|
+
try {
|
|
4185
|
+
encodeRow(table.columns.map((column, index) => ({
|
|
4186
|
+
...column,
|
|
4187
|
+
type: column.encrypted && values[index] instanceof Uint8Array
|
|
4188
|
+
? 'bytes'
|
|
4189
|
+
: localColumnType(column),
|
|
4190
|
+
})), values);
|
|
4191
|
+
}
|
|
4192
|
+
catch {
|
|
4193
|
+
throw new ClientSyncError('sync.local_corrupt', 'persisted optimistic row value is invalid');
|
|
4194
|
+
}
|
|
4195
|
+
}
|
|
3767
4196
|
// Record the row's scope keys from its scope columns (I2 refinement).
|
|
3768
4197
|
if (batch !== undefined) {
|
|
3769
4198
|
for (const [variable, column] of table.scopeColumnByVariable) {
|
|
@@ -3810,10 +4239,89 @@ export class SyncClient {
|
|
|
3810
4239
|
}
|
|
3811
4240
|
return scope;
|
|
3812
4241
|
}
|
|
3813
|
-
|
|
4242
|
+
/**
|
|
4243
|
+
* §7.1: the operation's values must encode under the current schema. Returns
|
|
4244
|
+
* the refusal message, or `undefined` when they encode. Structural failures
|
|
4245
|
+
* (a programming error, never caller data) propagate unchanged.
|
|
4246
|
+
*/
|
|
4247
|
+
#operationValuesError(table, values) {
|
|
4248
|
+
const declared = table.columns.map((column) => ({
|
|
4249
|
+
...column,
|
|
4250
|
+
type: localColumnType(column),
|
|
4251
|
+
}));
|
|
4252
|
+
const sparse = [];
|
|
4253
|
+
for (const column of table.columns) {
|
|
4254
|
+
const raw = values[column.name];
|
|
4255
|
+
if (raw === undefined) {
|
|
4256
|
+
sparse.push(undefined);
|
|
4257
|
+
continue;
|
|
4258
|
+
}
|
|
4259
|
+
// §7.1: the schema-agnostic outbox form serializes through JSON, which
|
|
4260
|
+
// turns NaN and ±Infinity into null. The authoring boundary refuses them
|
|
4261
|
+
// instead of recording a different value; the wire `f64` encoding itself
|
|
4262
|
+
// stays IEEE-754 binary64 (§2.4).
|
|
4263
|
+
if (localColumnType(column) === 'float' &&
|
|
4264
|
+
typeof raw === 'number' &&
|
|
4265
|
+
!Number.isFinite(raw))
|
|
4266
|
+
return `column ${column.name} (float) requires a finite number value`;
|
|
4267
|
+
try {
|
|
4268
|
+
sparse.push(jsonToRowValue(raw));
|
|
4269
|
+
}
|
|
4270
|
+
catch (error) {
|
|
4271
|
+
if (error instanceof ClientSyncError)
|
|
4272
|
+
return `column ${column.name}: ${error.message}`;
|
|
4273
|
+
throw error;
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
try {
|
|
4277
|
+
encodeSparseRow(declared, table.primaryKeyIndex, sparse);
|
|
4278
|
+
return undefined;
|
|
4279
|
+
}
|
|
4280
|
+
catch (error) {
|
|
4281
|
+
if (error instanceof EncodeError || error instanceof ClientSyncError)
|
|
4282
|
+
return error.message;
|
|
4283
|
+
throw error;
|
|
4284
|
+
}
|
|
4285
|
+
}
|
|
4286
|
+
/**
|
|
4287
|
+
* §7.4.4: a pending commit whose own values the current codec refuses (an
|
|
4288
|
+
* envelope or a type an earlier version accepted) leaves the outbox as
|
|
4289
|
+
* `sync.outbox_incompatible` instead of failing every replay and sync. The
|
|
4290
|
+
* commit goes through the rejection channel; encryption and storage failures
|
|
4291
|
+
* never reach this path.
|
|
4292
|
+
*/
|
|
4293
|
+
#dropUnencodableOutbox() {
|
|
4294
|
+
const failures = [];
|
|
4295
|
+
for (const commit of listOutbox(this.#db)) {
|
|
4296
|
+
const refused = commit.operations.some((op) => {
|
|
4297
|
+
if (op.op !== 'upsert' || op.values === undefined)
|
|
4298
|
+
return false;
|
|
4299
|
+
const table = this.#schema.tables.get(op.table);
|
|
4300
|
+
if (table === undefined)
|
|
4301
|
+
return false;
|
|
4302
|
+
return this.#operationValuesError(table, op.values) !== undefined;
|
|
4303
|
+
});
|
|
4304
|
+
if (refused)
|
|
4305
|
+
failures.push(commit);
|
|
4306
|
+
}
|
|
4307
|
+
// §7.4.4: every refused commit is classified before the FIFO drop pass, so
|
|
4308
|
+
// a rollback never replays a refused successor it has not removed yet.
|
|
4309
|
+
const classifiedFailures = new Set(failures.map((commit) => commit.clientCommitId));
|
|
4310
|
+
for (const commit of failures)
|
|
4311
|
+
this.#dropIncompatibleCommit(commit, LEGACY_VALUE_REFUSAL_MESSAGE, OUTBOX_INCOMPATIBLE_CODE, false, INVALID_STORED_VALUES_REASON, classifiedFailures);
|
|
4312
|
+
}
|
|
4313
|
+
#replayOutbox(scope, restoreBases = true, observe = true) {
|
|
3814
4314
|
if (scope?.length === 0)
|
|
3815
4315
|
return;
|
|
3816
|
-
|
|
4316
|
+
const replay = (batch) => {
|
|
4317
|
+
// §7.4.4: classify value-unencodable pending commits inside the
|
|
4318
|
+
// reconciliation transaction, so a commit persisted by an earlier version
|
|
4319
|
+
// is dropped atomically with the replay it would otherwise fail. A
|
|
4320
|
+
// scoped (partial) reconciliation leaves the classification to these full
|
|
4321
|
+
// boundaries and to the send-time drop, so a partial rebuild never drops
|
|
4322
|
+
// a commit whose other tables are outside the scope.
|
|
4323
|
+
if (scope === undefined)
|
|
4324
|
+
this.#dropUnencodableOutbox();
|
|
3817
4325
|
if (restoreBases)
|
|
3818
4326
|
restoreFailedBases(this.#db, this.#schema, scope);
|
|
3819
4327
|
const acknowledged = failedOverlayCommits(this.#db, true, scope, this.#schema);
|
|
@@ -3825,7 +4333,21 @@ export class SyncClient {
|
|
|
3825
4333
|
this.#applyOperationsLocally(commit.operations.filter((op) => scope === undefined ||
|
|
3826
4334
|
scope.includes(JSON.stringify([op.table, op.rowId])) ||
|
|
3827
4335
|
scope.includes(JSON.stringify([op.table]))), batch, true);
|
|
3828
|
-
}
|
|
4336
|
+
};
|
|
4337
|
+
const priorRejections = [...this.#rejections];
|
|
4338
|
+
try {
|
|
4339
|
+
if (observe)
|
|
4340
|
+
this.#applyBatch(replay);
|
|
4341
|
+
else
|
|
4342
|
+
this.#db.transaction(() => replay());
|
|
4343
|
+
}
|
|
4344
|
+
catch (error) {
|
|
4345
|
+
// SQLite rolled the reconciliation back: mirror that rollback for the
|
|
4346
|
+
// in-memory rejection cache, so a later failure in the same transaction
|
|
4347
|
+
// publishes no drop the durable journal did not keep.
|
|
4348
|
+
this.#rejections = priorRejections;
|
|
4349
|
+
throw error;
|
|
4350
|
+
}
|
|
3829
4351
|
}
|
|
3830
4352
|
#deleteUnreferencedBlobs() {
|
|
3831
4353
|
if (!this.#hasBlobs)
|