@syncular/client 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bun-database.d.ts +4 -1
- package/dist/bun-database.js +24 -1
- package/dist/client.d.ts +47 -0
- package/dist/client.js +326 -4
- package/dist/database.d.ts +27 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/invalidation.d.ts +9 -0
- package/dist/node-database.d.ts +4 -1
- package/dist/node-database.js +24 -1
- package/dist/previous-version.d.ts +207 -0
- package/dist/previous-version.js +732 -0
- package/dist/query-guard.d.ts +5 -0
- package/dist/query-guard.js +5 -0
- package/dist/schema.d.ts +4 -2
- package/dist/schema.js +4 -2
- package/dist/wasm-database.js +87 -35
- package/dist/worker-entry.js +15 -4
- package/dist/worker-host.d.ts +13 -1
- package/dist/worker-host.js +13 -0
- package/dist/worker-protocol.d.ts +16 -1
- package/package.json +3 -3
- package/src/bun-database.ts +26 -1
- package/src/client.ts +481 -4
- package/src/database.ts +28 -0
- package/src/index.ts +13 -0
- package/src/invalidation.ts +9 -0
- package/src/node-database.ts +26 -1
- package/src/previous-version.ts +1070 -0
- package/src/query-guard.ts +5 -0
- package/src/schema.ts +4 -2
- package/src/wasm-database.ts +114 -47
- package/src/worker-entry.ts +16 -4
- package/src/worker-host.ts +40 -0
- package/src/worker-protocol.ts +19 -0
package/src/client.ts
CHANGED
|
@@ -150,6 +150,30 @@ import {
|
|
|
150
150
|
recordCommitOutcome,
|
|
151
151
|
} from './outcomes';
|
|
152
152
|
import { assertReadOnlyQuery } from './query-guard';
|
|
153
|
+
import {
|
|
154
|
+
buildPreviousVersionAudit,
|
|
155
|
+
capturePreviousVersion,
|
|
156
|
+
clearPreviousVersionAudit,
|
|
157
|
+
clearPreviousVersionRefusal,
|
|
158
|
+
dropPreviousVersionContainer,
|
|
159
|
+
loadLocalSchemaDescriptor,
|
|
160
|
+
PREVIOUS_VERSION_CONTAINER_NAME,
|
|
161
|
+
PREVIOUS_VERSION_MAX_LIMIT,
|
|
162
|
+
type PreviousVersionAudit,
|
|
163
|
+
type PreviousVersionCaptureConfig,
|
|
164
|
+
type PreviousVersionReason,
|
|
165
|
+
type PreviousVersionReadSpec,
|
|
166
|
+
type PreviousVersionRecord,
|
|
167
|
+
type PreviousVersionSnapshot,
|
|
168
|
+
readPreviousVersionContainer,
|
|
169
|
+
readPreviousVersionRows,
|
|
170
|
+
setLocalSchemaDescriptor,
|
|
171
|
+
storedPreviousVersionAudit,
|
|
172
|
+
storedPreviousVersionRefusal,
|
|
173
|
+
withPreviousVersionContainer,
|
|
174
|
+
writePreviousVersionAudit,
|
|
175
|
+
writePreviousVersionRefusal,
|
|
176
|
+
} from './previous-version';
|
|
153
177
|
import {
|
|
154
178
|
type ClientSchema,
|
|
155
179
|
type CompiledClientSchema,
|
|
@@ -361,6 +385,31 @@ export interface SyncClientConfig {
|
|
|
361
385
|
* hosts must not materialize key bytes before their preflight has passed.
|
|
362
386
|
*/
|
|
363
387
|
readonly securityPreflight?: boolean;
|
|
388
|
+
/**
|
|
389
|
+
* RFC 0005 previous-version context. A feature flag, default off: when
|
|
390
|
+
* absent or `enabled: false`, a schema bump behaves exactly as 0.22.0 did
|
|
391
|
+
* apart from the schema-descriptor write and the unconditional orphan
|
|
392
|
+
* sweep. It is not a security control.
|
|
393
|
+
*
|
|
394
|
+
* When enabled, the §7.4.3 reset captures a bounded typed copy of the
|
|
395
|
+
* pre-reset rows and `previousVersionSnapshot()` can read it until
|
|
396
|
+
* coverage completes, the lease ends, a scope is revoked, the TTL passes,
|
|
397
|
+
* the data is purged, or the host discards it.
|
|
398
|
+
*/
|
|
399
|
+
readonly previousVersionContext?: {
|
|
400
|
+
readonly enabled: boolean;
|
|
401
|
+
/** Total captured bytes; default 8 MiB. */
|
|
402
|
+
readonly maxBytes?: number;
|
|
403
|
+
/** Total captured rows; default 20,000. */
|
|
404
|
+
readonly maxRows?: number;
|
|
405
|
+
/** Captured table count; default 32. */
|
|
406
|
+
readonly maxTables?: number;
|
|
407
|
+
/** Largest single captured row in bytes; default 1 MiB. */
|
|
408
|
+
readonly maxRowBytes?: number;
|
|
409
|
+
/** Aware-binary hygiene TTL; default 24h. It does not bound an
|
|
410
|
+
* unaware same-schema rollback, which runs none of this code. */
|
|
411
|
+
readonly maxAgeMs?: number;
|
|
412
|
+
};
|
|
364
413
|
}
|
|
365
414
|
|
|
366
415
|
/** The fail-closed local-replica security lifecycle shared by every host. */
|
|
@@ -526,6 +575,78 @@ function emptySummary(pushed: number): MutableSummary {
|
|
|
526
575
|
|
|
527
576
|
const LOG_EPOCH_META_KEY = 'logEpoch';
|
|
528
577
|
|
|
578
|
+
/** RFC 0005 defaults (D2/A4/D7). */
|
|
579
|
+
const PREVIOUS_VERSION_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
580
|
+
const PREVIOUS_VERSION_DEFAULT_MAX_ROWS = 20_000;
|
|
581
|
+
const PREVIOUS_VERSION_DEFAULT_MAX_TABLES = 32;
|
|
582
|
+
const PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES = 1024 * 1024;
|
|
583
|
+
const PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
584
|
+
|
|
585
|
+
function boundedBudget(
|
|
586
|
+
value: number | undefined,
|
|
587
|
+
fallback: number,
|
|
588
|
+
name: string,
|
|
589
|
+
): number {
|
|
590
|
+
if (value === undefined) return fallback;
|
|
591
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
592
|
+
throw new ClientSyncError(
|
|
593
|
+
'sync.invalid_request',
|
|
594
|
+
`previousVersionContext.${name} must be a positive safe integer`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
return value;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function resolvePreviousVersionCaptureConfig(
|
|
601
|
+
config: SyncClientConfig['previousVersionContext'],
|
|
602
|
+
): PreviousVersionCaptureConfig | undefined {
|
|
603
|
+
if (config === undefined || config.enabled !== true) return undefined;
|
|
604
|
+
return {
|
|
605
|
+
maxBytes: boundedBudget(
|
|
606
|
+
config.maxBytes,
|
|
607
|
+
PREVIOUS_VERSION_DEFAULT_MAX_BYTES,
|
|
608
|
+
'maxBytes',
|
|
609
|
+
),
|
|
610
|
+
maxRows: boundedBudget(
|
|
611
|
+
config.maxRows,
|
|
612
|
+
PREVIOUS_VERSION_DEFAULT_MAX_ROWS,
|
|
613
|
+
'maxRows',
|
|
614
|
+
),
|
|
615
|
+
maxTables: boundedBudget(
|
|
616
|
+
config.maxTables,
|
|
617
|
+
PREVIOUS_VERSION_DEFAULT_MAX_TABLES,
|
|
618
|
+
'maxTables',
|
|
619
|
+
),
|
|
620
|
+
maxRowBytes: boundedBudget(
|
|
621
|
+
config.maxRowBytes,
|
|
622
|
+
PREVIOUS_VERSION_DEFAULT_MAX_ROW_BYTES,
|
|
623
|
+
'maxRowBytes',
|
|
624
|
+
),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function previousVersionMaxAgeMs(
|
|
629
|
+
config: SyncClientConfig['previousVersionContext'],
|
|
630
|
+
): number {
|
|
631
|
+
return boundedBudget(
|
|
632
|
+
config?.maxAgeMs,
|
|
633
|
+
PREVIOUS_VERSION_DEFAULT_MAX_AGE_MS,
|
|
634
|
+
'maxAgeMs',
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** RFC 0005 D7: `limit` defaults to 50 and is capped at 200. */
|
|
639
|
+
function resolvePreviousVersionLimit(limit: number | undefined): number {
|
|
640
|
+
if (limit === undefined) return 50;
|
|
641
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
642
|
+
throw new ClientSyncError(
|
|
643
|
+
'sync.invalid_request',
|
|
644
|
+
'previousVersionSnapshot limit must be a positive number',
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return Math.min(PREVIOUS_VERSION_MAX_LIMIT, Math.trunc(limit));
|
|
648
|
+
}
|
|
649
|
+
|
|
529
650
|
function isFinalPushResult(frame: PushResultFrame): boolean {
|
|
530
651
|
return (
|
|
531
652
|
frame.status !== 'rejected' ||
|
|
@@ -575,6 +696,8 @@ export class SyncClient {
|
|
|
575
696
|
readonly #schema: CompiledClientSchema;
|
|
576
697
|
/** §5.11 client-side encryption config; undefined ⇒ E2EE off. */
|
|
577
698
|
#encryption: EncryptionConfig | undefined;
|
|
699
|
+
/** RFC 0005 resolved capture budgets; undefined ⇒ feature off. */
|
|
700
|
+
readonly #previousVersion: PreviousVersionCaptureConfig | undefined;
|
|
578
701
|
#securityLifecycle: SecurityLifecycle;
|
|
579
702
|
readonly #now: () => number;
|
|
580
703
|
readonly #outcomeRetentionMaxEntries: number;
|
|
@@ -683,6 +806,13 @@ export class SyncClient {
|
|
|
683
806
|
);
|
|
684
807
|
}
|
|
685
808
|
this.#outcomeRetentionMaxEntries = outcomeRetentionMaxEntries;
|
|
809
|
+
// RFC 0005: the container needs a sibling database file. A host without
|
|
810
|
+
// that capability (native bridges today) has no storage for it, so the
|
|
811
|
+
// feature resolves as not configured rather than pretending to capture.
|
|
812
|
+
this.#previousVersion =
|
|
813
|
+
this.#db.openSibling === undefined
|
|
814
|
+
? undefined
|
|
815
|
+
: resolvePreviousVersionCaptureConfig(config.previousVersionContext);
|
|
686
816
|
this.#hasBlobs = schemaHasBlobs(this.#schema);
|
|
687
817
|
}
|
|
688
818
|
|
|
@@ -735,6 +865,10 @@ export class SyncClient {
|
|
|
735
865
|
// before the first sync round. A fresh install (no marker) is treated as
|
|
736
866
|
// already at the generated version.
|
|
737
867
|
this.#detectAndResetSchema();
|
|
868
|
+
// RFC 0005 D9: a container with missing/stale metadata, or past its TTL, is
|
|
869
|
+
// discarded at boot before anything can read it. The orphan/stale sweep is
|
|
870
|
+
// unconditional; the TTL is aware-binary hygiene only.
|
|
871
|
+
this.#reconcilePreviousVersionAtBoot();
|
|
738
872
|
// A registration remains app intent only while its table exists in the
|
|
739
873
|
// running schema. Removed-table registrations would poison every pull.
|
|
740
874
|
const subscriptions = pruneUnknownSubscriptions(
|
|
@@ -785,14 +919,26 @@ export class SyncClient {
|
|
|
785
919
|
const markerJson = getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY);
|
|
786
920
|
if (markerJson === undefined) {
|
|
787
921
|
// Fresh install: the tables just created match the running code.
|
|
788
|
-
|
|
789
|
-
|
|
922
|
+
this.#db.transaction(() => {
|
|
923
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
924
|
+
setMeta(
|
|
925
|
+
this.#db,
|
|
926
|
+
LOCAL_SCHEMA_VERSION_KEY,
|
|
927
|
+
String(this.#schema.version),
|
|
928
|
+
);
|
|
929
|
+
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
930
|
+
});
|
|
790
931
|
return;
|
|
791
932
|
}
|
|
792
933
|
const marker = Number(markerJson);
|
|
793
934
|
if (marker === this.#schema.version) {
|
|
794
|
-
// Same-version opens remain self-healing for absent tables/indexes
|
|
795
|
-
|
|
935
|
+
// Same-version opens remain self-healing for absent tables/indexes, and
|
|
936
|
+
// RFC 0005 D1 backfills the descriptor for a database first opened by
|
|
937
|
+
// 0.22.0 or earlier WITHOUT a schema bump.
|
|
938
|
+
this.#db.transaction(() => {
|
|
939
|
+
ensureLocalSyncedSchema(this.#db, this.#schema);
|
|
940
|
+
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
941
|
+
});
|
|
796
942
|
return;
|
|
797
943
|
}
|
|
798
944
|
this.#runSchemaReset();
|
|
@@ -806,9 +952,31 @@ export class SyncClient {
|
|
|
806
952
|
* The bump is idempotent by the marker (rewritten last).
|
|
807
953
|
*/
|
|
808
954
|
#runSchemaReset(): void {
|
|
955
|
+
const previousVersion = Number(
|
|
956
|
+
getMeta(this.#db, LOCAL_SCHEMA_VERSION_KEY) ?? this.#schema.version,
|
|
957
|
+
);
|
|
809
958
|
this.#setUpgrading(true);
|
|
959
|
+
// D5 step 1: the container file is swept FIRST, then captured into its own
|
|
960
|
+
// transaction, then the reset transaction runs with the marker LAST. A
|
|
961
|
+
// crash before the reset commit leaves the OLD marker, so the next boot
|
|
962
|
+
// re-runs the reset and the sweep removes the container: idempotent by the
|
|
963
|
+
// existing marker, with no cross-file transaction.
|
|
964
|
+
this.#sweepPreviousVersionContainer();
|
|
965
|
+
this.#capturePreviousVersion(previousVersion);
|
|
810
966
|
this.#applyBatch((batch) => {
|
|
811
967
|
this.#db.transaction(() => {
|
|
968
|
+
if (this.#previousVersion !== undefined) {
|
|
969
|
+
writePreviousVersionAudit(
|
|
970
|
+
this.#db,
|
|
971
|
+
buildPreviousVersionAudit(
|
|
972
|
+
this.#schema,
|
|
973
|
+
listOutbox(this.#db),
|
|
974
|
+
previousVersion,
|
|
975
|
+
this.#schema.version,
|
|
976
|
+
this.#now(),
|
|
977
|
+
),
|
|
978
|
+
);
|
|
979
|
+
}
|
|
812
980
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
813
981
|
resetSubscriptionsForBump(this.#db);
|
|
814
982
|
setMeta(
|
|
@@ -816,6 +984,7 @@ export class SyncClient {
|
|
|
816
984
|
LOCAL_SCHEMA_VERSION_KEY,
|
|
817
985
|
String(this.#schema.version),
|
|
818
986
|
);
|
|
987
|
+
setLocalSchemaDescriptor(this.#db, this.#schema);
|
|
819
988
|
});
|
|
820
989
|
// Whole-DB reset: every synced table's rows changed (I1 eviction-shaped).
|
|
821
990
|
for (const table of this.#schema.tables.values()) batch.table(table.name);
|
|
@@ -827,11 +996,84 @@ export class SyncClient {
|
|
|
827
996
|
this.#replayOutbox();
|
|
828
997
|
}
|
|
829
998
|
|
|
999
|
+
/**
|
|
1000
|
+
* RFC 0005 D5 steps 2-5: capture the previous-version context into its own
|
|
1001
|
+
* database file, in its own transaction, before the wipe. A refused capture
|
|
1002
|
+
* stores nothing and records its reason for the read surface.
|
|
1003
|
+
*/
|
|
1004
|
+
#capturePreviousVersion(previousVersion: number): void {
|
|
1005
|
+
const config = this.#previousVersion;
|
|
1006
|
+
if (config === undefined) return;
|
|
1007
|
+
const descriptor = loadLocalSchemaDescriptor(this.#db);
|
|
1008
|
+
if (descriptor === undefined || descriptor.version !== previousVersion) {
|
|
1009
|
+
writePreviousVersionRefusal(this.#db, 'no-previous-descriptor', {
|
|
1010
|
+
tables: 0,
|
|
1011
|
+
rows: 0,
|
|
1012
|
+
bytes: 0,
|
|
1013
|
+
});
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const handle = this.#db.openSibling?.(PREVIOUS_VERSION_CONTAINER_NAME);
|
|
1017
|
+
if (handle === undefined) return;
|
|
1018
|
+
let captured = false;
|
|
1019
|
+
try {
|
|
1020
|
+
const outcome = capturePreviousVersion(
|
|
1021
|
+
this.#db,
|
|
1022
|
+
handle.database,
|
|
1023
|
+
descriptor,
|
|
1024
|
+
this.#schema,
|
|
1025
|
+
config,
|
|
1026
|
+
this.#now(),
|
|
1027
|
+
);
|
|
1028
|
+
if (outcome.ok) {
|
|
1029
|
+
captured = true;
|
|
1030
|
+
clearPreviousVersionRefusal(this.#db);
|
|
1031
|
+
} else {
|
|
1032
|
+
writePreviousVersionRefusal(
|
|
1033
|
+
this.#db,
|
|
1034
|
+
outcome.reason,
|
|
1035
|
+
outcome.measurement,
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
} finally {
|
|
1039
|
+
handle.close();
|
|
1040
|
+
// A refused capture must leave no container file at all.
|
|
1041
|
+
if (!captured) handle.removeFile();
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/** D5 step 1: unconditional orphan sweep on the container file. */
|
|
1046
|
+
#sweepPreviousVersionContainer(): void {
|
|
1047
|
+
if (this.#dropPreviousVersionContainerFile()) {
|
|
1048
|
+
clearPreviousVersionRefusal(this.#db);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Drop the container file when present and report whether it was. Shared by
|
|
1054
|
+
* the orphan sweep and the discard path; the file is gone either way.
|
|
1055
|
+
*/
|
|
1056
|
+
#dropPreviousVersionContainerFile(): boolean {
|
|
1057
|
+
return (
|
|
1058
|
+
withPreviousVersionContainer(
|
|
1059
|
+
this.#db,
|
|
1060
|
+
(container) => {
|
|
1061
|
+
dropPreviousVersionContainer(container);
|
|
1062
|
+
return true;
|
|
1063
|
+
},
|
|
1064
|
+
true,
|
|
1065
|
+
) === true
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
830
1069
|
/** §2.1 reset after the server reports a different log continuity. */
|
|
831
1070
|
#runLogEpochReset(logEpoch: string): string[] {
|
|
832
1071
|
const subscriptions = loadSubscriptions(this.#db);
|
|
833
1072
|
const pending = listOutbox(this.#db);
|
|
834
1073
|
this.#setUpgrading(true);
|
|
1074
|
+
// RFC 0005 D5: the log-epoch reset is not a schema bump. It performs the
|
|
1075
|
+
// orphan sweep but captures no new shadow.
|
|
1076
|
+
this.#sweepPreviousVersionContainer();
|
|
835
1077
|
this.#applyBatch((batch) => {
|
|
836
1078
|
this.#db.transaction(() => {
|
|
837
1079
|
dropAndRecreateSyncedTables(this.#db, this.#schema);
|
|
@@ -1006,6 +1248,8 @@ export class SyncClient {
|
|
|
1006
1248
|
query(sql: string, params?: readonly SqlValue[]): SqlRow[] {
|
|
1007
1249
|
this.#requireActive();
|
|
1008
1250
|
assertReadOnlyQuery(sql);
|
|
1251
|
+
// RFC 0005's container lives in a separate database file, so this
|
|
1252
|
+
// connection cannot see it and there is no table-access guard to run.
|
|
1009
1253
|
return stripSyncColumns(this.#db.query(sql, params));
|
|
1010
1254
|
}
|
|
1011
1255
|
|
|
@@ -1068,6 +1312,229 @@ export class SyncClient {
|
|
|
1068
1312
|
});
|
|
1069
1313
|
}
|
|
1070
1314
|
|
|
1315
|
+
// -- previous-version context (RFC 0005) ----------------------------------
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* RFC 0005 D7: read captured rows from the previous local schema. `state` is
|
|
1319
|
+
* always `'previousVersion'`; the container never makes `querySnapshot()`
|
|
1320
|
+
* coverage complete.
|
|
1321
|
+
*/
|
|
1322
|
+
previousVersionSnapshot(
|
|
1323
|
+
spec: PreviousVersionReadSpec,
|
|
1324
|
+
): PreviousVersionSnapshot {
|
|
1325
|
+
this.#requireActive();
|
|
1326
|
+
const currentVersion = this.#schema.version;
|
|
1327
|
+
if (this.#previousVersion === undefined) {
|
|
1328
|
+
return this.#previousVersionUnavailable(currentVersion, 'not-configured');
|
|
1329
|
+
}
|
|
1330
|
+
const record = this.#readPreviousVersionRecord();
|
|
1331
|
+
const nowMs = this.#now();
|
|
1332
|
+
if (this.#previousVersionLeaseInactive(nowMs)) {
|
|
1333
|
+
this.#discardPreviousVersion();
|
|
1334
|
+
return this.#previousVersionUnavailable(currentVersion, 'lease-inactive');
|
|
1335
|
+
}
|
|
1336
|
+
if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
|
|
1337
|
+
this.#discardPreviousVersion();
|
|
1338
|
+
return this.#previousVersionUnavailable(currentVersion, 'scope-revoked');
|
|
1339
|
+
}
|
|
1340
|
+
// Coverage completion is a live predicate: the container is normally
|
|
1341
|
+
// dropped by the sync path first, so a read can be the first observer.
|
|
1342
|
+
if (this.#previousVersionCoverageComplete()) {
|
|
1343
|
+
this.#discardPreviousVersion();
|
|
1344
|
+
return this.#previousVersionUnavailable(
|
|
1345
|
+
currentVersion,
|
|
1346
|
+
'coverage-complete',
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
if (record === undefined) {
|
|
1350
|
+
const refusal = storedPreviousVersionRefusal(this.#db);
|
|
1351
|
+
return this.#previousVersionUnavailable(
|
|
1352
|
+
currentVersion,
|
|
1353
|
+
refusal?.reason ?? 'no-previous-descriptor',
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
if (
|
|
1357
|
+
nowMs - record.createdAtMs >
|
|
1358
|
+
previousVersionMaxAgeMs(this.#config.previousVersionContext)
|
|
1359
|
+
) {
|
|
1360
|
+
this.#discardPreviousVersion();
|
|
1361
|
+
return this.#previousVersionUnavailable(currentVersion, 'expired');
|
|
1362
|
+
}
|
|
1363
|
+
const table = record.tables.find((entry) => entry.name === spec.table);
|
|
1364
|
+
if (table === undefined) {
|
|
1365
|
+
throw new ClientSyncError(
|
|
1366
|
+
'sync.invalid_request',
|
|
1367
|
+
`previousVersionSnapshot names unknown previous table ${JSON.stringify(spec.table)}`,
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
const read = withPreviousVersionContainer(this.#db, (container) =>
|
|
1371
|
+
readPreviousVersionRows(
|
|
1372
|
+
container,
|
|
1373
|
+
table,
|
|
1374
|
+
spec.rowIds?.map(String) ?? [],
|
|
1375
|
+
resolvePreviousVersionLimit(spec.limit),
|
|
1376
|
+
),
|
|
1377
|
+
);
|
|
1378
|
+
if (read === undefined) {
|
|
1379
|
+
return this.#previousVersionUnavailable(
|
|
1380
|
+
currentVersion,
|
|
1381
|
+
'no-previous-descriptor',
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
return {
|
|
1385
|
+
state: 'previousVersion',
|
|
1386
|
+
available: true,
|
|
1387
|
+
previousVersion: record.previousVersion,
|
|
1388
|
+
currentVersion,
|
|
1389
|
+
rows: read.rows,
|
|
1390
|
+
truncated: read.truncated,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** RFC 0005 D6: the pre-reset compatibility audit, advisory only. */
|
|
1395
|
+
previousVersionAudit(): PreviousVersionAudit | undefined {
|
|
1396
|
+
this.#requireActive();
|
|
1397
|
+
return storedPreviousVersionAudit(this.#db);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* RFC 0005 A2/D9: the executable downgrade step. Drop the container file and
|
|
1402
|
+
* both metadata records, and report whether anything was present.
|
|
1403
|
+
*
|
|
1404
|
+
* Authorized cleanup, so it is gated only on `start()` like
|
|
1405
|
+
* `purgeLocalData`, NOT on `active`: RFC 0006 runs the discard consumer
|
|
1406
|
+
* inside the quiesced security-preflight window, after the barrier and
|
|
1407
|
+
* before reactivation. Reads and the audit stay active-gated.
|
|
1408
|
+
*/
|
|
1409
|
+
previousVersionDiscard(): { present: boolean; discarded: boolean } {
|
|
1410
|
+
this.#requireStarted();
|
|
1411
|
+
const present = this.#dropPreviousVersion();
|
|
1412
|
+
return { present, discarded: present };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
#previousVersionUnavailable(
|
|
1416
|
+
currentVersion: number,
|
|
1417
|
+
reason: PreviousVersionReason,
|
|
1418
|
+
): PreviousVersionSnapshot {
|
|
1419
|
+
return {
|
|
1420
|
+
state: 'previousVersion',
|
|
1421
|
+
available: false,
|
|
1422
|
+
currentVersion,
|
|
1423
|
+
reason,
|
|
1424
|
+
rows: [],
|
|
1425
|
+
truncated: false,
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
#previousVersionExists(): boolean {
|
|
1430
|
+
return this.#db.siblingExists?.(PREVIOUS_VERSION_CONTAINER_NAME) === true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Read the container's own metadata; opens the file for that window only. */
|
|
1434
|
+
#readPreviousVersionRecord(): PreviousVersionRecord | undefined {
|
|
1435
|
+
return withPreviousVersionContainer(this.#db, readPreviousVersionContainer);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** RFC 0005 A2: presence for `statusSnapshot()`, without creating the file. */
|
|
1439
|
+
#previousVersionStatus(): { present: boolean; createdAtMs?: number } {
|
|
1440
|
+
const record = this.#readPreviousVersionRecord();
|
|
1441
|
+
if (record === undefined) return { present: this.#previousVersionExists() };
|
|
1442
|
+
return { present: true, createdAtMs: record.createdAtMs };
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
#previousVersionLeaseInactive(nowMs: number): boolean {
|
|
1446
|
+
const lease = this.#leaseState;
|
|
1447
|
+
if (lease?.errorCode !== undefined) return true;
|
|
1448
|
+
return lease?.expiresAtMs !== undefined && lease.expiresAtMs <= nowMs;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
/**
|
|
1452
|
+
* RFC 0005 D7: the §7.4.5 replacement-coverage predicate — every active
|
|
1453
|
+
* subscription has `cursor >= 0` and no resume token. Window units own
|
|
1454
|
+
* subscriptions, so their pendency is included. An empty active set is not
|
|
1455
|
+
* completion.
|
|
1456
|
+
*/
|
|
1457
|
+
#previousVersionCoverageComplete(): boolean {
|
|
1458
|
+
const active = loadSubscriptions(this.#db).filter(
|
|
1459
|
+
(sub) => sub.status === 'active',
|
|
1460
|
+
);
|
|
1461
|
+
if (active.length === 0) return false;
|
|
1462
|
+
return active.every(
|
|
1463
|
+
(sub) => sub.cursor >= 0 && sub.bootstrapState === undefined,
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Remove the container file and both metadata records. Returns whether
|
|
1469
|
+
* anything was present. No status notification — callers that change client
|
|
1470
|
+
* state wrap it with {@link #discardPreviousVersion}.
|
|
1471
|
+
*/
|
|
1472
|
+
#dropPreviousVersion(): boolean {
|
|
1473
|
+
const present =
|
|
1474
|
+
this.#dropPreviousVersionContainerFile() ||
|
|
1475
|
+
storedPreviousVersionRefusal(this.#db) !== undefined ||
|
|
1476
|
+
storedPreviousVersionAudit(this.#db) !== undefined;
|
|
1477
|
+
this.#db.transaction(() => {
|
|
1478
|
+
clearPreviousVersionRefusal(this.#db);
|
|
1479
|
+
clearPreviousVersionAudit(this.#db);
|
|
1480
|
+
});
|
|
1481
|
+
return present;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
#discardPreviousVersion(): boolean {
|
|
1485
|
+
const present = this.#dropPreviousVersion();
|
|
1486
|
+
if (present) this.#applyBatch((batch) => batch.status());
|
|
1487
|
+
return present;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** RFC 0005 D7: lifetime triggers evaluated after a sync round. */
|
|
1491
|
+
#previousVersionLifetimeCheck(): void {
|
|
1492
|
+
if (this.#previousVersion === undefined) return;
|
|
1493
|
+
const nowMs = this.#now();
|
|
1494
|
+
if (this.#previousVersionLeaseInactive(nowMs)) {
|
|
1495
|
+
this.#discardPreviousVersion();
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const record = this.#readPreviousVersionRecord();
|
|
1499
|
+
if (record === undefined) return;
|
|
1500
|
+
if (loadSubscriptions(this.#db).some((sub) => sub.status === 'revoked')) {
|
|
1501
|
+
this.#discardPreviousVersion();
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
if (
|
|
1505
|
+
nowMs - record.createdAtMs >
|
|
1506
|
+
previousVersionMaxAgeMs(this.#config.previousVersionContext)
|
|
1507
|
+
) {
|
|
1508
|
+
this.#discardPreviousVersion();
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (this.#previousVersionCoverageComplete()) {
|
|
1512
|
+
this.#discardPreviousVersion();
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* RFC 0005 D9 boot hygiene: an orphan/stale container is discarded before any
|
|
1518
|
+
* read. The TTL runs only for an aware binary; it makes no claim about an
|
|
1519
|
+
* unaware same-schema rollback, which executes none of this code.
|
|
1520
|
+
*/
|
|
1521
|
+
#reconcilePreviousVersionAtBoot(): void {
|
|
1522
|
+
if (!this.#previousVersionExists()) return;
|
|
1523
|
+
const record = this.#readPreviousVersionRecord();
|
|
1524
|
+
let discard =
|
|
1525
|
+
record === undefined || record.currentVersion !== this.#schema.version;
|
|
1526
|
+
if (
|
|
1527
|
+
!discard &&
|
|
1528
|
+
record !== undefined &&
|
|
1529
|
+
this.#previousVersion !== undefined &&
|
|
1530
|
+
this.#now() - record.createdAtMs >
|
|
1531
|
+
previousVersionMaxAgeMs(this.#config.previousVersionContext)
|
|
1532
|
+
) {
|
|
1533
|
+
discard = true;
|
|
1534
|
+
}
|
|
1535
|
+
if (discard) this.#dropPreviousVersion();
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1071
1538
|
// -- live-query invalidation ----------------------------------------------
|
|
1072
1539
|
|
|
1073
1540
|
/**
|
|
@@ -1369,6 +1836,7 @@ export class SyncClient {
|
|
|
1369
1836
|
leaseState: this.#leaseState,
|
|
1370
1837
|
schemaFloor: this.#schemaFloor,
|
|
1371
1838
|
syncNeeded: this.#needsPull,
|
|
1839
|
+
previousVersionContext: this.#previousVersionStatus(),
|
|
1372
1840
|
};
|
|
1373
1841
|
}
|
|
1374
1842
|
|
|
@@ -2367,6 +2835,10 @@ export class SyncClient {
|
|
|
2367
2835
|
}
|
|
2368
2836
|
return { alreadyApplied: true, purgedRows: 0, droppedCommits: 0 };
|
|
2369
2837
|
}
|
|
2838
|
+
// RFC 0005 D7: the container file cannot be a purge target (its name is not
|
|
2839
|
+
// in the running schema), so purge drops it as a fixed step, before any
|
|
2840
|
+
// mirror row is touched and unconditionally.
|
|
2841
|
+
this.#dropPreviousVersion();
|
|
2370
2842
|
|
|
2371
2843
|
const rejectionCount = this.#rejections.length;
|
|
2372
2844
|
try {
|
|
@@ -2623,6 +3095,8 @@ export class SyncClient {
|
|
|
2623
3095
|
setMeta(this.#db, 'leaseState', JSON.stringify(next));
|
|
2624
3096
|
batch.status();
|
|
2625
3097
|
});
|
|
3098
|
+
// RFC 0005 D7: a lease stop state ends the previous-version context.
|
|
3099
|
+
if (next.errorCode !== undefined) this.#discardPreviousVersion();
|
|
2626
3100
|
}
|
|
2627
3101
|
|
|
2628
3102
|
/** The request-level lease error codes (§7.3.4): stop-and-surface. */
|
|
@@ -3776,6 +4250,9 @@ export class SyncClient {
|
|
|
3776
4250
|
) {
|
|
3777
4251
|
this.#setUpgrading(false);
|
|
3778
4252
|
}
|
|
4253
|
+
// RFC 0005 D7: coverage completion, lease end, scope revocation and TTL are
|
|
4254
|
+
// evaluated once per round, after the apply.
|
|
4255
|
+
this.#previousVersionLifetimeCheck();
|
|
3779
4256
|
return { ...summary, bootstrapping };
|
|
3780
4257
|
}
|
|
3781
4258
|
|
package/src/database.ts
CHANGED
|
@@ -9,6 +9,24 @@ export type SqlValue = string | number | bigint | boolean | Uint8Array | null;
|
|
|
9
9
|
|
|
10
10
|
export type SqlRow = Record<string, SqlValue>;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* RFC 0005: a second database file in this database's own storage scope (the
|
|
14
|
+
* same on-disk directory, or the same OPFS SAH pool directory). Used to keep
|
|
15
|
+
* the previous-version container off the connection `query()` and unaware code
|
|
16
|
+
* use. The read guarantee is narrow: an older client's ordinary query
|
|
17
|
+
* connection does not attach this file, so no SQL it runs can reach the
|
|
18
|
+
* container. It is NOT protection against arbitrary same-origin access and NOT
|
|
19
|
+
* protection against native filesystem access.
|
|
20
|
+
*/
|
|
21
|
+
export interface SiblingDatabase {
|
|
22
|
+
/** The open connection; the caller closes it before removal. */
|
|
23
|
+
readonly database: ClientDatabase;
|
|
24
|
+
/** Close the handle. A live handle blocks physical removal. */
|
|
25
|
+
close(): void;
|
|
26
|
+
/** Best-effort physical removal of the backing file. Close the handle first. */
|
|
27
|
+
removeFile(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export interface ClientDatabase {
|
|
13
31
|
/** Execute a single statement (no result rows). */
|
|
14
32
|
exec(sql: string, params?: readonly SqlValue[]): void;
|
|
@@ -32,6 +50,16 @@ export interface ClientDatabase {
|
|
|
32
50
|
alias: string,
|
|
33
51
|
fn: () => T | Promise<T>,
|
|
34
52
|
): Promise<T>;
|
|
53
|
+
/**
|
|
54
|
+
* RFC 0005: open (creating on demand) the named sibling database file.
|
|
55
|
+
* `undefined` means the host provides no sibling capability, which makes the
|
|
56
|
+
* previous-version feature unavailable there. The NAME is code-derived and is
|
|
57
|
+
* not persisted in the replica database (data minimisation; not a security
|
|
58
|
+
* control).
|
|
59
|
+
*/
|
|
60
|
+
openSibling?(name: string): SiblingDatabase | undefined;
|
|
61
|
+
/** RFC 0005: non-creating existence probe for {@link openSibling}. */
|
|
62
|
+
siblingExists?(name: string): boolean;
|
|
35
63
|
close(): void;
|
|
36
64
|
}
|
|
37
65
|
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,19 @@ export * from './multi-tab';
|
|
|
28
28
|
export * from './naming';
|
|
29
29
|
export * from './outbox';
|
|
30
30
|
export * from './outcomes';
|
|
31
|
+
// RFC 0005: explicit public surface. Everything else in the module is
|
|
32
|
+
// implementation detail used by `client.ts` (capture, container file, durable
|
|
33
|
+
// refusal/audit codecs), not package API. The feature is opt-in and its
|
|
34
|
+
// consumer-facing contract is exactly the read spec and the two result types.
|
|
35
|
+
export {
|
|
36
|
+
decodePreviousVersionAuditResult,
|
|
37
|
+
decodePreviousVersionDiscardResult,
|
|
38
|
+
decodePreviousVersionSnapshot,
|
|
39
|
+
type PreviousVersionAudit,
|
|
40
|
+
type PreviousVersionReadSpec,
|
|
41
|
+
type PreviousVersionReason,
|
|
42
|
+
type PreviousVersionSnapshot,
|
|
43
|
+
} from './previous-version';
|
|
31
44
|
export * from './query-guard';
|
|
32
45
|
export * from './reactive-store';
|
|
33
46
|
export * from './remote';
|
package/src/invalidation.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface SyncStatusSnapshot {
|
|
|
30
30
|
readonly leaseState: LeaseState | undefined;
|
|
31
31
|
readonly schemaFloor: SchemaFloor | undefined;
|
|
32
32
|
readonly syncNeeded: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* RFC 0005 A2: whether a previous-version cache is present, so a host's
|
|
35
|
+
* update/rollback path can require its discard. `createdAtMs` is the capture
|
|
36
|
+
* time when a readable cache exists.
|
|
37
|
+
*/
|
|
38
|
+
readonly previousVersionContext: {
|
|
39
|
+
readonly present: boolean;
|
|
40
|
+
readonly createdAtMs?: number;
|
|
41
|
+
};
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export interface ClientChangeBatch {
|