@syncular/server 0.15.47 → 0.16.1
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 +48 -5
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +3 -12
- package/dist/authoritative-query.d.ts +14 -6
- package/dist/authoritative-query.js +60 -82
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +8 -2
- package/dist/d1-storage.js +134 -26
- package/dist/errors.js +6 -0
- package/dist/events.d.ts +2 -1
- package/dist/frame-bytes.d.ts +2 -2
- package/dist/frame-bytes.js +33 -14
- package/dist/handler.js +58 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/operations.d.ts +2 -0
- package/dist/operations.js +25 -5
- package/dist/postgres-storage.d.ts +9 -3
- package/dist/postgres-storage.js +110 -20
- package/dist/prune.d.ts +3 -1
- package/dist/prune.js +18 -14
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +74 -37
- package/dist/push.js +5 -5
- package/dist/realtime.d.ts +4 -1
- package/dist/realtime.js +33 -9
- package/dist/restore.d.ts +13 -0
- package/dist/restore.js +13 -0
- package/dist/s3-segment-store.js +10 -1
- package/dist/seed.js +36 -4
- package/dist/segment-download.js +5 -2
- package/dist/segment-store.d.ts +3 -0
- package/dist/segment-store.js +1 -0
- package/dist/sqlite-bun-driver.d.ts +2 -1
- package/dist/sqlite-bun-driver.js +5 -2
- package/dist/sqlite-bun.js +2 -2
- package/dist/sqlite-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +7 -0
- package/dist/sqlite-image.d.ts +3 -3
- package/dist/sqlite-image.js +10 -6
- package/dist/sqlite-node.js +2 -2
- package/dist/sqlite-segment-store.js +14 -5
- package/dist/sqlite-storage.d.ts +8 -2
- package/dist/sqlite-storage.js +147 -36
- package/dist/storage-errors.d.ts +1 -1
- package/dist/storage-errors.js +3 -0
- package/dist/storage.d.ts +38 -10
- package/package.json +2 -2
- package/src/admin.ts +7 -15
- package/src/authoritative-query.ts +89 -94
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +16 -1
- package/src/d1-storage.ts +193 -29
- package/src/errors.ts +6 -0
- package/src/events.ts +2 -1
- package/src/frame-bytes.ts +40 -14
- package/src/handler.ts +102 -29
- package/src/index.ts +1 -0
- package/src/operations.ts +37 -4
- package/src/postgres-storage.ts +184 -38
- package/src/prune.ts +29 -15
- package/src/pull.ts +90 -41
- package/src/push.ts +5 -5
- package/src/realtime.ts +46 -7
- package/src/restore.ts +28 -0
- package/src/s3-segment-store.ts +10 -1
- package/src/seed.ts +46 -4
- package/src/segment-download.ts +11 -2
- package/src/segment-store.ts +4 -0
- package/src/sqlite-bun-driver.ts +6 -2
- package/src/sqlite-bun.ts +2 -2
- package/src/sqlite-dialect.ts +7 -0
- package/src/sqlite-image.ts +26 -15
- package/src/sqlite-node.ts +2 -2
- package/src/sqlite-segment-store.ts +18 -4
- package/src/sqlite-storage.ts +203 -39
- package/src/storage-errors.ts +10 -1
- package/src/storage.ts +57 -10
package/src/sqlite-storage.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune';
|
|
2
|
+
import { StorageQueryError } from './storage-errors';
|
|
3
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage';
|
|
1
4
|
/**
|
|
2
5
|
* SQLite server storage over the shared synchronous driver.
|
|
3
6
|
*
|
|
@@ -62,6 +65,7 @@ import type {
|
|
|
62
65
|
IndexRowScanQuery,
|
|
63
66
|
NewCommit,
|
|
64
67
|
NewReaction,
|
|
68
|
+
PartitionRegistryEntry,
|
|
65
69
|
PrunedReactionCounts,
|
|
66
70
|
ReactionClaimQuery,
|
|
67
71
|
ReactionFailure,
|
|
@@ -389,7 +393,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
389
393
|
#tables: ReadonlyMap<string, CompiledTable> | undefined;
|
|
390
394
|
#schemaVersion: number | undefined;
|
|
391
395
|
|
|
392
|
-
async #
|
|
396
|
+
async #serializeWrite<T>(operation: () => T): Promise<T> {
|
|
393
397
|
const previous = this.#transactionTail;
|
|
394
398
|
let release!: () => void;
|
|
395
399
|
this.#transactionTail = new Promise<void>((resolve) => {
|
|
@@ -409,6 +413,14 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
409
413
|
}
|
|
410
414
|
this.db = db;
|
|
411
415
|
this.db.exec(SQLITE_DDL);
|
|
416
|
+
const clientColumns = this.db
|
|
417
|
+
.query<{ name: string }, []>('PRAGMA table_info("sync_clients")')
|
|
418
|
+
.all();
|
|
419
|
+
if (!clientColumns.some((column) => column.name === 'wire_version')) {
|
|
420
|
+
this.db.exec(
|
|
421
|
+
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1',
|
|
422
|
+
);
|
|
423
|
+
}
|
|
412
424
|
}
|
|
413
425
|
|
|
414
426
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -504,6 +516,106 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
504
516
|
this.#schemaVersion = schema.version;
|
|
505
517
|
}
|
|
506
518
|
|
|
519
|
+
async touchPartition(
|
|
520
|
+
partition: string,
|
|
521
|
+
authenticatedAtMs: number,
|
|
522
|
+
initialLogEpoch: string,
|
|
523
|
+
): Promise<PartitionRegistryEntry> {
|
|
524
|
+
if (initialLogEpoch.length === 0) {
|
|
525
|
+
throw new Error('initial log epoch must be non-empty');
|
|
526
|
+
}
|
|
527
|
+
this.db
|
|
528
|
+
.query(
|
|
529
|
+
`INSERT INTO sync_partition_registry(
|
|
530
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
531
|
+
) VALUES (?,?,?)
|
|
532
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
533
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
534
|
+
)
|
|
535
|
+
.run(partition, initialLogEpoch, authenticatedAtMs);
|
|
536
|
+
const row = this.db
|
|
537
|
+
.query<
|
|
538
|
+
{
|
|
539
|
+
log_epoch: string;
|
|
540
|
+
epoch_required: number;
|
|
541
|
+
last_authenticated_at_ms: number;
|
|
542
|
+
},
|
|
543
|
+
[string]
|
|
544
|
+
>(
|
|
545
|
+
`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
546
|
+
FROM sync_partition_registry WHERE partition=?`,
|
|
547
|
+
)
|
|
548
|
+
.get(partition);
|
|
549
|
+
if (row === null)
|
|
550
|
+
throw new Error('partition registry write did not persist');
|
|
551
|
+
return {
|
|
552
|
+
partition,
|
|
553
|
+
logEpoch: row.log_epoch,
|
|
554
|
+
epochRequired: row.epoch_required === 1,
|
|
555
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async rotatePartitionLogEpoch(
|
|
560
|
+
partition: string,
|
|
561
|
+
logEpoch: string,
|
|
562
|
+
authenticatedAtMs: number,
|
|
563
|
+
): Promise<PartitionRegistryEntry> {
|
|
564
|
+
if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
|
|
565
|
+
return this.#serializeWrite(() => {
|
|
566
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
567
|
+
try {
|
|
568
|
+
this.db
|
|
569
|
+
.query(
|
|
570
|
+
`INSERT INTO sync_partition_registry(
|
|
571
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
572
|
+
) VALUES (?,?,1,?)
|
|
573
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
574
|
+
log_epoch=excluded.log_epoch,
|
|
575
|
+
epoch_required=1,
|
|
576
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
577
|
+
)
|
|
578
|
+
.run(partition, logEpoch, authenticatedAtMs);
|
|
579
|
+
this.db
|
|
580
|
+
.query('DELETE FROM sync_clients WHERE partition=?')
|
|
581
|
+
.run(partition);
|
|
582
|
+
this.db.exec('COMMIT');
|
|
583
|
+
} catch (error) {
|
|
584
|
+
this.db.exec('ROLLBACK');
|
|
585
|
+
throw error;
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
partition,
|
|
589
|
+
logEpoch,
|
|
590
|
+
epochRequired: true,
|
|
591
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
|
|
597
|
+
return this.db
|
|
598
|
+
.query<
|
|
599
|
+
{
|
|
600
|
+
partition: string;
|
|
601
|
+
log_epoch: string;
|
|
602
|
+
epoch_required: number;
|
|
603
|
+
last_authenticated_at_ms: number;
|
|
604
|
+
},
|
|
605
|
+
[]
|
|
606
|
+
>(
|
|
607
|
+
`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
608
|
+
FROM sync_partition_registry ORDER BY partition`,
|
|
609
|
+
)
|
|
610
|
+
.all()
|
|
611
|
+
.map((row) => ({
|
|
612
|
+
partition: row.partition,
|
|
613
|
+
logEpoch: row.log_epoch,
|
|
614
|
+
epochRequired: row.epoch_required === 1,
|
|
615
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
|
|
507
619
|
/**
|
|
508
620
|
* Migration rewrite: keyset-paged walk of a row table. When `oldLayout` is
|
|
509
621
|
* given every payload re-encodes under
|
|
@@ -616,7 +728,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
616
728
|
}
|
|
617
729
|
const prepared = bindAuthoritativePartition(
|
|
618
730
|
prepareAuthoritativeQuery(
|
|
619
|
-
query.
|
|
731
|
+
query.plan,
|
|
620
732
|
query.params,
|
|
621
733
|
query.tables,
|
|
622
734
|
this.#tables,
|
|
@@ -654,6 +766,14 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
654
766
|
}
|
|
655
767
|
}
|
|
656
768
|
|
|
769
|
+
async getPartitionLogEpoch(partition: string): Promise<string | undefined> {
|
|
770
|
+
return this.db
|
|
771
|
+
.query<{ log_epoch: string }, [string]>(
|
|
772
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
|
|
773
|
+
)
|
|
774
|
+
.get(partition)?.log_epoch;
|
|
775
|
+
}
|
|
776
|
+
|
|
657
777
|
async getHorizonSeq(partition: string): Promise<number> {
|
|
658
778
|
const row = this.db
|
|
659
779
|
.query<{ horizon_seq: number }, [string]>(
|
|
@@ -664,27 +784,62 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
664
784
|
}
|
|
665
785
|
|
|
666
786
|
async setHorizonSeq(partition: string, seq: number): Promise<void> {
|
|
667
|
-
this
|
|
668
|
-
.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
787
|
+
await this.#serializeWrite(() => {
|
|
788
|
+
this.db
|
|
789
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
790
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
791
|
+
.run(partition, seq);
|
|
792
|
+
});
|
|
673
793
|
}
|
|
674
794
|
|
|
675
|
-
async pruneCommitsThrough(
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
795
|
+
async pruneCommitsThrough(
|
|
796
|
+
partition: string,
|
|
797
|
+
query: CommitPruneQuery,
|
|
798
|
+
): Promise<CommitPruneResult> {
|
|
799
|
+
validateCommitPruneQuery(query);
|
|
800
|
+
return this.#serializeWrite(() => {
|
|
801
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
802
|
+
try {
|
|
803
|
+
const epoch = this.db
|
|
804
|
+
.query<{ log_epoch: string }, [string]>(
|
|
805
|
+
'SELECT log_epoch FROM sync_partition_registry WHERE partition=?',
|
|
806
|
+
)
|
|
807
|
+
.get(partition)?.log_epoch;
|
|
808
|
+
if (epoch !== query.logEpoch)
|
|
809
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
810
|
+
const previousHorizonSeq =
|
|
811
|
+
this.db
|
|
812
|
+
.query<{ horizon_seq: number }, [string]>(
|
|
813
|
+
'SELECT horizon_seq FROM sync_partitions WHERE partition=?',
|
|
814
|
+
)
|
|
815
|
+
.get(partition)?.horizon_seq ?? 0;
|
|
816
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
817
|
+
this.db
|
|
818
|
+
.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?)
|
|
819
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq`)
|
|
820
|
+
.run(partition, horizonSeq);
|
|
821
|
+
const removed = this.db
|
|
822
|
+
.query('DELETE FROM sync_commits WHERE partition=? AND commit_seq<=?')
|
|
823
|
+
.run(partition, horizonSeq);
|
|
824
|
+
this.db
|
|
825
|
+
.query('DELETE FROM sync_changes WHERE partition=? AND commit_seq<=?')
|
|
826
|
+
.run(partition, horizonSeq);
|
|
827
|
+
this.db
|
|
828
|
+
.query(
|
|
829
|
+
'DELETE FROM sync_change_scopes WHERE partition=? AND commit_seq<=?',
|
|
830
|
+
)
|
|
831
|
+
.run(partition, horizonSeq);
|
|
832
|
+
this.db.exec('COMMIT');
|
|
833
|
+
return {
|
|
834
|
+
previousHorizonSeq,
|
|
835
|
+
horizonSeq,
|
|
836
|
+
removedCommits: Number(removed.changes),
|
|
837
|
+
};
|
|
838
|
+
} catch (error) {
|
|
839
|
+
this.db.exec('ROLLBACK');
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
688
843
|
}
|
|
689
844
|
|
|
690
845
|
async getCommitSeqBefore(
|
|
@@ -738,7 +893,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
738
893
|
query: ReactionClaimQuery,
|
|
739
894
|
): Promise<StoredReaction[]> {
|
|
740
895
|
if (query.types.length === 0 || query.limit <= 0) return [];
|
|
741
|
-
return this.#
|
|
896
|
+
return this.#serializeWrite(() => {
|
|
742
897
|
const typeParams = query.types.map(() => '?').join(',');
|
|
743
898
|
const records = this.db
|
|
744
899
|
.query<SqliteReactionRecord, (string | number)[]>(
|
|
@@ -786,7 +941,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
786
941
|
leaseOwner: string,
|
|
787
942
|
completedAtMs: number,
|
|
788
943
|
): Promise<boolean> {
|
|
789
|
-
return this.#
|
|
944
|
+
return this.#serializeWrite(() => {
|
|
790
945
|
const result = this.db
|
|
791
946
|
.query(
|
|
792
947
|
`UPDATE sync_reactions
|
|
@@ -806,7 +961,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
806
961
|
leaseOwner: string,
|
|
807
962
|
leaseExpiresAtMs: number,
|
|
808
963
|
): Promise<boolean> {
|
|
809
|
-
return this.#
|
|
964
|
+
return this.#serializeWrite(() => {
|
|
810
965
|
const result = this.db
|
|
811
966
|
.query(
|
|
812
967
|
`UPDATE sync_reactions SET lease_expires_at_ms=?
|
|
@@ -824,7 +979,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
824
979
|
update: ReactionFailureUpdate,
|
|
825
980
|
): Promise<boolean> {
|
|
826
981
|
const retry = update.retryAtMs !== undefined;
|
|
827
|
-
return this.#
|
|
982
|
+
return this.#serializeWrite(() => {
|
|
828
983
|
const result = this.db
|
|
829
984
|
.query(
|
|
830
985
|
`UPDATE sync_reactions
|
|
@@ -850,7 +1005,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
850
1005
|
idempotencyKey: string,
|
|
851
1006
|
nowMs: number,
|
|
852
1007
|
): Promise<boolean> {
|
|
853
|
-
return this.#
|
|
1008
|
+
return this.#serializeWrite(() => {
|
|
854
1009
|
const result = this.db
|
|
855
1010
|
.query(
|
|
856
1011
|
`UPDATE sync_reactions
|
|
@@ -905,7 +1060,7 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
905
1060
|
query: ReactionPruneQuery,
|
|
906
1061
|
): Promise<PrunedReactionCounts> {
|
|
907
1062
|
if (query.limit <= 0) return { completed: 0, deadLetter: 0 };
|
|
908
|
-
return this.#
|
|
1063
|
+
return this.#serializeWrite(() => {
|
|
909
1064
|
const records = this.db
|
|
910
1065
|
.query<
|
|
911
1066
|
{ status: 'completed' | 'dead-letter' },
|
|
@@ -1077,19 +1232,21 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1077
1232
|
{
|
|
1078
1233
|
client_id: string;
|
|
1079
1234
|
actor_id: string;
|
|
1235
|
+
wire_version: number;
|
|
1080
1236
|
cursor: number;
|
|
1081
1237
|
subscriptions: string;
|
|
1082
1238
|
updated_at_ms: number;
|
|
1083
1239
|
},
|
|
1084
1240
|
[string, string]
|
|
1085
1241
|
>(
|
|
1086
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1242
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1087
1243
|
)
|
|
1088
1244
|
.get(partition, clientId);
|
|
1089
1245
|
if (record === null) return undefined;
|
|
1090
1246
|
return {
|
|
1091
1247
|
clientId: record.client_id,
|
|
1092
1248
|
actorId: record.actor_id,
|
|
1249
|
+
wireVersion: record.wire_version,
|
|
1093
1250
|
cursor: record.cursor,
|
|
1094
1251
|
updatedAtMs: record.updated_at_ms,
|
|
1095
1252
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1102,18 +1259,31 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1102
1259
|
): Promise<void> {
|
|
1103
1260
|
this.db
|
|
1104
1261
|
.query(
|
|
1105
|
-
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
1262
|
+
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
|
|
1106
1263
|
)
|
|
1107
1264
|
.run(
|
|
1108
1265
|
partition,
|
|
1109
1266
|
record.clientId,
|
|
1110
1267
|
record.actorId,
|
|
1268
|
+
record.wireVersion,
|
|
1111
1269
|
record.cursor,
|
|
1112
1270
|
JSON.stringify(record.subscriptions),
|
|
1113
1271
|
record.updatedAtMs,
|
|
1114
1272
|
);
|
|
1115
1273
|
}
|
|
1116
1274
|
|
|
1275
|
+
async getActiveClientCursorFloor(
|
|
1276
|
+
partition: string,
|
|
1277
|
+
cutoffMs: number,
|
|
1278
|
+
): Promise<number | null> {
|
|
1279
|
+
const row = this.db
|
|
1280
|
+
.query<{ cursor: number | null }, [string, number]>(
|
|
1281
|
+
'SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?',
|
|
1282
|
+
)
|
|
1283
|
+
.get(partition, cutoffMs);
|
|
1284
|
+
return row!.cursor;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1117
1287
|
async listClientCursors(partition: string): Promise<ClientCursorInfo[]> {
|
|
1118
1288
|
const records = this.db
|
|
1119
1289
|
.query<
|
|
@@ -1187,18 +1357,20 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1187
1357
|
{
|
|
1188
1358
|
client_id: string;
|
|
1189
1359
|
actor_id: string;
|
|
1360
|
+
wire_version: number;
|
|
1190
1361
|
cursor: number;
|
|
1191
1362
|
subscriptions: string;
|
|
1192
1363
|
updated_at_ms: number;
|
|
1193
1364
|
},
|
|
1194
1365
|
[string]
|
|
1195
1366
|
>(
|
|
1196
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1367
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1197
1368
|
)
|
|
1198
1369
|
.all(partition);
|
|
1199
1370
|
return records.map((record) => ({
|
|
1200
1371
|
clientId: record.client_id,
|
|
1201
1372
|
actorId: record.actor_id,
|
|
1373
|
+
wireVersion: record.wire_version,
|
|
1202
1374
|
cursor: record.cursor,
|
|
1203
1375
|
updatedAtMs: record.updated_at_ms,
|
|
1204
1376
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1324,14 +1496,6 @@ export class SqliteServerStorage implements ServerStorage {
|
|
|
1324
1496
|
}
|
|
1325
1497
|
|
|
1326
1498
|
async listPartitions(): Promise<string[]> {
|
|
1327
|
-
|
|
1328
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1329
|
-
const rows = this.db
|
|
1330
|
-
.query<{ partition: string }, []>(
|
|
1331
|
-
`SELECT partition FROM sync_partitions
|
|
1332
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`,
|
|
1333
|
-
)
|
|
1334
|
-
.all();
|
|
1335
|
-
return rows.map((r) => r.partition);
|
|
1499
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1336
1500
|
}
|
|
1337
1501
|
}
|
package/src/storage-errors.ts
CHANGED
|
@@ -19,10 +19,19 @@ export type StorageQueryErrorCode =
|
|
|
19
19
|
| 'sync.storage.index_not_found'
|
|
20
20
|
| 'sync.storage.index_not_materialized'
|
|
21
21
|
| 'sync.storage.index_value_count_mismatch'
|
|
22
|
-
| 'sync.storage.invalid_limit'
|
|
22
|
+
| 'sync.storage.invalid_limit'
|
|
23
|
+
| 'sync.storage.prune_epoch_mismatch'
|
|
24
|
+
| 'sync.storage.partition_unregistered'
|
|
25
|
+
| 'sync.storage.invalid_prune_cursor';
|
|
23
26
|
|
|
24
27
|
const STORAGE_QUERY_MESSAGES: Readonly<Record<StorageQueryErrorCode, string>> =
|
|
25
28
|
{
|
|
29
|
+
'sync.storage.prune_epoch_mismatch':
|
|
30
|
+
'partition log epoch changed; recompute retention inputs',
|
|
31
|
+
'sync.storage.partition_unregistered':
|
|
32
|
+
'pruning requires a registered partition',
|
|
33
|
+
'sync.storage.invalid_prune_cursor':
|
|
34
|
+
'pruning requires a non-negative safe integer cursor and a non-empty log epoch',
|
|
26
35
|
'sync.storage.scan_requires_scope':
|
|
27
36
|
'scope-indexed row scans require at least one scope variable',
|
|
28
37
|
'sync.storage.index_not_found':
|
package/src/storage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AuthoritativeRelationPlan } from './authoritative-query';
|
|
1
2
|
/**
|
|
2
3
|
* Storage interface (defined by the SPEC's needs, implementation-agnostic).
|
|
3
4
|
*
|
|
@@ -19,6 +20,17 @@
|
|
|
19
20
|
import type { PushOperationResult, RowValue, ScopeMap } from '@syncular/core';
|
|
20
21
|
import type { CompiledSchema } from './schema';
|
|
21
22
|
|
|
23
|
+
export interface CommitPruneQuery {
|
|
24
|
+
readonly logEpoch: string;
|
|
25
|
+
readonly throughSeq: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommitPruneResult {
|
|
29
|
+
readonly previousHorizonSeq: number;
|
|
30
|
+
readonly horizonSeq: number;
|
|
31
|
+
readonly removedCommits: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
/** The current stored state of a synced row. */
|
|
23
35
|
export interface StoredRow {
|
|
24
36
|
readonly rowId: string;
|
|
@@ -164,6 +176,8 @@ export interface ClientSubscription {
|
|
|
164
176
|
export interface ClientRecord {
|
|
165
177
|
readonly clientId: string;
|
|
166
178
|
readonly actorId: string;
|
|
179
|
+
/** SSP2 version last accepted from this client; selects realtime deltas. */
|
|
180
|
+
readonly wireVersion: number;
|
|
167
181
|
/** Minimum `nextCursor` across the last pull's active subscriptions. */
|
|
168
182
|
readonly cursor: number;
|
|
169
183
|
readonly updatedAtMs: number;
|
|
@@ -220,6 +234,14 @@ export interface ClientCursorInfo {
|
|
|
220
234
|
readonly updatedAtMs: number;
|
|
221
235
|
}
|
|
222
236
|
|
|
237
|
+
/** Durable partition identity refreshed after host authentication (§2.1). */
|
|
238
|
+
export interface PartitionRegistryEntry {
|
|
239
|
+
readonly partition: string;
|
|
240
|
+
readonly logEpoch: string;
|
|
241
|
+
readonly epochRequired: boolean;
|
|
242
|
+
readonly lastAuthenticatedAtMs: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
223
245
|
/**
|
|
224
246
|
* Commit-log metadata (no change payloads) for the admin/console read
|
|
225
247
|
* surface. `changeCount` is the number of changes the commit carries;
|
|
@@ -271,7 +293,7 @@ export type AuthoritativeQueryValue =
|
|
|
271
293
|
|
|
272
294
|
export interface AuthoritativeQueryRequest {
|
|
273
295
|
/** Generated, positional SQLite-family SQL. It never comes from the request. */
|
|
274
|
-
readonly
|
|
296
|
+
readonly plan: AuthoritativeRelationPlan;
|
|
275
297
|
readonly params: readonly AuthoritativeQueryValue[];
|
|
276
298
|
/** Generated dependency set, used to validate and partition every relation. */
|
|
277
299
|
readonly tables: readonly string[];
|
|
@@ -397,16 +419,38 @@ export interface ServerStorage {
|
|
|
397
419
|
*/
|
|
398
420
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
399
421
|
|
|
422
|
+
/** Create or refresh the authenticated partition registry row (§2.1). */
|
|
423
|
+
touchPartition(
|
|
424
|
+
partition: string,
|
|
425
|
+
authenticatedAtMs: number,
|
|
426
|
+
initialLogEpoch: string,
|
|
427
|
+
): Promise<PartitionRegistryEntry>;
|
|
428
|
+
/** Rotate log continuity after restore and discard stale cursor records. */
|
|
429
|
+
rotatePartitionLogEpoch(
|
|
430
|
+
partition: string,
|
|
431
|
+
logEpoch: string,
|
|
432
|
+
authenticatedAtMs: number,
|
|
433
|
+
): Promise<PartitionRegistryEntry>;
|
|
434
|
+
/** Registry entries ordered by partition for maintenance loops. */
|
|
435
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
436
|
+
|
|
437
|
+
/** Read continuity without refreshing authenticated activity. */
|
|
438
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
439
|
+
|
|
400
440
|
begin(partition: string): Promise<StorageTransaction>;
|
|
401
441
|
|
|
402
442
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
403
443
|
getHorizonSeq(partition: string): Promise<number>;
|
|
444
|
+
/** Monotonic within the current epoch; use atomic pruning for maintenance. */
|
|
404
445
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
405
446
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
447
|
+
* Atomically verifies the log epoch, advances the horizon monotonically,
|
|
448
|
+
* and removes log/change/scope records through the effective horizon.
|
|
408
449
|
*/
|
|
409
|
-
pruneCommitsThrough(
|
|
450
|
+
pruneCommitsThrough(
|
|
451
|
+
partition: string,
|
|
452
|
+
query: CommitPruneQuery,
|
|
453
|
+
): Promise<CommitPruneResult>;
|
|
410
454
|
/** Newest commitSeq created strictly before the timestamp; 0 if none. */
|
|
411
455
|
getCommitSeqBefore(
|
|
412
456
|
partition: string,
|
|
@@ -516,7 +560,12 @@ export interface ServerStorage {
|
|
|
516
560
|
clientId: string,
|
|
517
561
|
): Promise<ClientRecord | undefined>;
|
|
518
562
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
519
|
-
/**
|
|
563
|
+
/** Minimum cursor with updatedAtMs >= cutoff; null when none are active. */
|
|
564
|
+
getActiveClientCursorFloor(
|
|
565
|
+
partition: string,
|
|
566
|
+
cutoffMs: number,
|
|
567
|
+
): Promise<number | null>;
|
|
568
|
+
/** Cursor records for client listings and administrative counts. */
|
|
520
569
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
521
570
|
|
|
522
571
|
/**
|
|
@@ -551,10 +600,8 @@ export interface ServerStorage {
|
|
|
551
600
|
* change scope index (never a log scan).
|
|
552
601
|
* `getRowScopes`: the (table, rowId) row's current server_version and
|
|
553
602
|
* stored scopes without decoding its payload — the row inspector.
|
|
554
|
-
* `listPartitions`:
|
|
555
|
-
*
|
|
556
|
-
* records, sorted. Powers the console's fleet view / partition picker;
|
|
557
|
-
* deliberately NOT partition-scoped (the one cross-partition read).
|
|
603
|
+
* `listPartitions`: the partition-only compatibility view of
|
|
604
|
+
* `listPartitionRegistry`, sorted.
|
|
558
605
|
*/
|
|
559
606
|
listClientRecords?(partition: string): Promise<ClientRecord[]>;
|
|
560
607
|
listCommitMetadata?(
|
|
@@ -572,7 +619,7 @@ export interface ServerStorage {
|
|
|
572
619
|
): Promise<
|
|
573
620
|
{ serverVersion: number; scopes: Record<string, string> } | undefined
|
|
574
621
|
>;
|
|
575
|
-
listPartitions
|
|
622
|
+
listPartitions(): Promise<string[]>;
|
|
576
623
|
}
|
|
577
624
|
|
|
578
625
|
/** A row referencing a blob, with the scopes needed to authorize download. */
|