@syncular/server 0.15.46 → 0.15.48
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 +17 -4
- package/dist/admin.d.ts +1 -5
- package/dist/admin.js +2 -7
- package/dist/blob-handlers.js +4 -1
- package/dist/context.d.ts +5 -2
- package/dist/context.js +4 -0
- package/dist/d1-storage.d.ts +4 -1
- package/dist/d1-storage.js +75 -11
- 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-bun.d.ts +2 -0
- package/dist/index-bun.js +2 -0
- package/dist/index-node.d.ts +2 -0
- package/dist/index-node.js +2 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -6
- package/dist/operations.js +2 -1
- package/dist/postgres-storage.d.ts +5 -2
- package/dist/postgres-storage.js +71 -10
- package/dist/pull.d.ts +1 -1
- package/dist/pull.js +12 -6
- 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-blob-store.d.ts +4 -9
- package/dist/sqlite-blob-store.js +5 -10
- package/dist/sqlite-bun-driver.d.ts +12 -0
- package/dist/sqlite-bun-driver.js +30 -0
- package/dist/sqlite-bun.d.ts +24 -0
- package/dist/sqlite-bun.js +40 -0
- package/dist/sqlite-dialect.d.ts +8 -8
- package/dist/sqlite-dialect.js +9 -2
- package/dist/sqlite-driver.d.ts +26 -0
- package/dist/sqlite-driver.js +8 -0
- package/dist/sqlite-image.d.ts +7 -9
- package/dist/sqlite-image.js +26 -28
- package/dist/sqlite-lease-store.d.ts +4 -9
- package/dist/sqlite-lease-store.js +5 -10
- package/dist/sqlite-node-driver.d.ts +10 -0
- package/dist/sqlite-node-driver.js +30 -0
- package/dist/sqlite-node.d.ts +24 -0
- package/dist/sqlite-node.js +50 -0
- package/dist/sqlite-segment-store.d.ts +4 -10
- package/dist/sqlite-segment-store.js +20 -14
- package/dist/sqlite-storage.d.ts +7 -12
- package/dist/sqlite-storage.js +89 -16
- package/dist/storage-errors.js +4 -1
- package/dist/storage.d.ts +18 -5
- package/package.json +18 -3
- package/src/admin.ts +3 -9
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +18 -2
- package/src/d1-storage.ts +107 -12
- 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-bun.ts +9 -0
- package/src/index-node.ts +9 -0
- package/src/index.ts +9 -6
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +102 -13
- package/src/pull.ts +12 -1
- 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-blob-store.ts +11 -10
- package/src/sqlite-bun-driver.ts +46 -0
- package/src/sqlite-bun.ts +53 -0
- package/src/sqlite-dialect.ts +14 -7
- package/src/sqlite-driver.ts +44 -0
- package/src/sqlite-image.ts +44 -49
- package/src/sqlite-lease-store.ts +11 -10
- package/src/sqlite-node-driver.ts +46 -0
- package/src/sqlite-node.ts +62 -0
- package/src/sqlite-segment-store.ts +29 -15
- package/src/sqlite-storage.ts +131 -19
- package/src/storage-errors.ts +4 -1
- package/src/storage.ts +28 -5
package/src/d1-storage.ts
CHANGED
|
@@ -95,6 +95,7 @@ import type {
|
|
|
95
95
|
IndexRowScanQuery,
|
|
96
96
|
NewCommit,
|
|
97
97
|
NewReaction,
|
|
98
|
+
PartitionRegistryEntry,
|
|
98
99
|
PrunedReactionCounts,
|
|
99
100
|
ReactionClaimQuery,
|
|
100
101
|
ReactionFailure,
|
|
@@ -752,6 +753,14 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
752
753
|
for (const statement of sqliteDdlStatements()) {
|
|
753
754
|
await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
|
|
754
755
|
}
|
|
756
|
+
const { results } = await this.#db
|
|
757
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
758
|
+
.all<{ name: string }>();
|
|
759
|
+
if (!results.some((column) => column.name === 'wire_version')) {
|
|
760
|
+
await this.#db.exec(
|
|
761
|
+
'ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;',
|
|
762
|
+
);
|
|
763
|
+
}
|
|
755
764
|
}
|
|
756
765
|
|
|
757
766
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
@@ -859,6 +868,95 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
859
868
|
this.#schemaVersion = schema.version;
|
|
860
869
|
}
|
|
861
870
|
|
|
871
|
+
async touchPartition(
|
|
872
|
+
partition: string,
|
|
873
|
+
authenticatedAtMs: number,
|
|
874
|
+
initialLogEpoch: string,
|
|
875
|
+
): Promise<PartitionRegistryEntry> {
|
|
876
|
+
if (initialLogEpoch.length === 0) {
|
|
877
|
+
throw new Error('initial log epoch must be non-empty');
|
|
878
|
+
}
|
|
879
|
+
await this.#db
|
|
880
|
+
.prepare(
|
|
881
|
+
`INSERT INTO sync_partition_registry(
|
|
882
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
883
|
+
) VALUES (?,?,?)
|
|
884
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
885
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
886
|
+
)
|
|
887
|
+
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
888
|
+
.run();
|
|
889
|
+
const row = await this.#db
|
|
890
|
+
.prepare(
|
|
891
|
+
`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
892
|
+
FROM sync_partition_registry WHERE partition=?`,
|
|
893
|
+
)
|
|
894
|
+
.bind(partition)
|
|
895
|
+
.first<{
|
|
896
|
+
log_epoch: string;
|
|
897
|
+
epoch_required: number;
|
|
898
|
+
last_authenticated_at_ms: number;
|
|
899
|
+
}>();
|
|
900
|
+
if (row === null)
|
|
901
|
+
throw new Error('partition registry write did not persist');
|
|
902
|
+
return {
|
|
903
|
+
partition,
|
|
904
|
+
logEpoch: row.log_epoch,
|
|
905
|
+
epochRequired: row.epoch_required === 1,
|
|
906
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async rotatePartitionLogEpoch(
|
|
911
|
+
partition: string,
|
|
912
|
+
logEpoch: string,
|
|
913
|
+
authenticatedAtMs: number,
|
|
914
|
+
): Promise<PartitionRegistryEntry> {
|
|
915
|
+
if (logEpoch.length === 0) throw new Error('log epoch must be non-empty');
|
|
916
|
+
await this.#db.batch([
|
|
917
|
+
this.#db
|
|
918
|
+
.prepare(
|
|
919
|
+
`INSERT INTO sync_partition_registry(
|
|
920
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
921
|
+
) VALUES (?,?,1,?)
|
|
922
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
923
|
+
log_epoch=excluded.log_epoch,
|
|
924
|
+
epoch_required=1,
|
|
925
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`,
|
|
926
|
+
)
|
|
927
|
+
.bind(partition, logEpoch, authenticatedAtMs),
|
|
928
|
+
this.#db
|
|
929
|
+
.prepare('DELETE FROM sync_clients WHERE partition=?')
|
|
930
|
+
.bind(partition),
|
|
931
|
+
]);
|
|
932
|
+
return {
|
|
933
|
+
partition,
|
|
934
|
+
logEpoch,
|
|
935
|
+
epochRequired: true,
|
|
936
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async listPartitionRegistry(): Promise<PartitionRegistryEntry[]> {
|
|
941
|
+
const { results } = await this.#db
|
|
942
|
+
.prepare(
|
|
943
|
+
`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
944
|
+
FROM sync_partition_registry ORDER BY partition`,
|
|
945
|
+
)
|
|
946
|
+
.all<{
|
|
947
|
+
partition: string;
|
|
948
|
+
log_epoch: string;
|
|
949
|
+
epoch_required: number;
|
|
950
|
+
last_authenticated_at_ms: number;
|
|
951
|
+
}>();
|
|
952
|
+
return results.map((row) => ({
|
|
953
|
+
partition: row.partition,
|
|
954
|
+
logEpoch: row.log_epoch,
|
|
955
|
+
epochRequired: row.epoch_required === 1,
|
|
956
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
|
|
862
960
|
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
863
961
|
async #rewriteRows(
|
|
864
962
|
table: CompiledTable,
|
|
@@ -1390,12 +1488,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1390
1488
|
): Promise<ClientRecord | undefined> {
|
|
1391
1489
|
const record = await this.#db
|
|
1392
1490
|
.prepare(
|
|
1393
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1491
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?',
|
|
1394
1492
|
)
|
|
1395
1493
|
.bind(partition, clientId)
|
|
1396
1494
|
.first<{
|
|
1397
1495
|
client_id: string;
|
|
1398
1496
|
actor_id: string;
|
|
1497
|
+
wire_version: number;
|
|
1399
1498
|
cursor: number;
|
|
1400
1499
|
subscriptions: string;
|
|
1401
1500
|
updated_at_ms: number;
|
|
@@ -1404,6 +1503,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1404
1503
|
return {
|
|
1405
1504
|
clientId: record.client_id,
|
|
1406
1505
|
actorId: record.actor_id,
|
|
1506
|
+
wireVersion: record.wire_version,
|
|
1407
1507
|
cursor: record.cursor,
|
|
1408
1508
|
updatedAtMs: record.updated_at_ms,
|
|
1409
1509
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1416,12 +1516,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1416
1516
|
): Promise<void> {
|
|
1417
1517
|
await this.#db
|
|
1418
1518
|
.prepare(
|
|
1419
|
-
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
1519
|
+
'INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)',
|
|
1420
1520
|
)
|
|
1421
1521
|
.bind(
|
|
1422
1522
|
partition,
|
|
1423
1523
|
record.clientId,
|
|
1424
1524
|
record.actorId,
|
|
1525
|
+
record.wireVersion,
|
|
1425
1526
|
record.cursor,
|
|
1426
1527
|
JSON.stringify(record.subscriptions),
|
|
1427
1528
|
record.updatedAtMs,
|
|
@@ -1494,12 +1595,13 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1494
1595
|
async listClientRecords(partition: string): Promise<ClientRecord[]> {
|
|
1495
1596
|
const { results } = await this.#db
|
|
1496
1597
|
.prepare(
|
|
1497
|
-
'SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1598
|
+
'SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC',
|
|
1498
1599
|
)
|
|
1499
1600
|
.bind(partition)
|
|
1500
1601
|
.all<{
|
|
1501
1602
|
client_id: string;
|
|
1502
1603
|
actor_id: string;
|
|
1604
|
+
wire_version: number;
|
|
1503
1605
|
cursor: number;
|
|
1504
1606
|
subscriptions: string;
|
|
1505
1607
|
updated_at_ms: number;
|
|
@@ -1507,6 +1609,7 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1507
1609
|
return results.map((record) => ({
|
|
1508
1610
|
clientId: record.client_id,
|
|
1509
1611
|
actorId: record.actor_id,
|
|
1612
|
+
wireVersion: record.wire_version,
|
|
1510
1613
|
cursor: record.cursor,
|
|
1511
1614
|
updatedAtMs: record.updated_at_ms,
|
|
1512
1615
|
subscriptions: JSON.parse(record.subscriptions) as ClientSubscription[],
|
|
@@ -1628,14 +1731,6 @@ export class D1ServerStorage implements ServerStorage {
|
|
|
1628
1731
|
}
|
|
1629
1732
|
|
|
1630
1733
|
async listPartitions(): Promise<string[]> {
|
|
1631
|
-
|
|
1632
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1633
|
-
const { results } = await this.#db
|
|
1634
|
-
.prepare(
|
|
1635
|
-
`SELECT partition FROM sync_partitions
|
|
1636
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`,
|
|
1637
|
-
)
|
|
1638
|
-
.all<{ partition: string }>();
|
|
1639
|
-
return results.map((r) => r.partition);
|
|
1734
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1640
1735
|
}
|
|
1641
1736
|
}
|
package/src/errors.ts
CHANGED
|
@@ -199,6 +199,12 @@ export const ERROR_CATALOG: Readonly<Record<string, ErrorCatalogEntry>> = {
|
|
|
199
199
|
recommendedAction: 'upgradeClient',
|
|
200
200
|
httpStatus: 400,
|
|
201
201
|
},
|
|
202
|
+
'sync.client_wire_unsupported': {
|
|
203
|
+
category: 'schema-mismatch',
|
|
204
|
+
retryable: false,
|
|
205
|
+
recommendedAction: 'upgradeClient',
|
|
206
|
+
httpStatus: 400,
|
|
207
|
+
},
|
|
202
208
|
'sync.websocket_connection_limit': {
|
|
203
209
|
category: 'rate-limited',
|
|
204
210
|
retryable: true,
|
package/src/events.ts
CHANGED
|
@@ -29,10 +29,11 @@ export interface RequestHandledEvent {
|
|
|
29
29
|
/**
|
|
30
30
|
* `ok` — response streamed to END;
|
|
31
31
|
* `schema_floor` — §2.4 required-schema answer;
|
|
32
|
+
* `reset` — §2.1 log-epoch reset answer;
|
|
32
33
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
33
34
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
34
35
|
*/
|
|
35
|
-
readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
|
|
36
|
+
readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
|
|
36
37
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
37
38
|
readonly errorCode?: string;
|
|
38
39
|
readonly pushCommits: number;
|
package/src/frame-bytes.ts
CHANGED
|
@@ -18,7 +18,14 @@ import {
|
|
|
18
18
|
type SubStartFrame,
|
|
19
19
|
} from '@syncular/core';
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const stubHeader = (wireVersion: number): RespHeaderFrame =>
|
|
22
|
+
wireVersion >= 2
|
|
23
|
+
? {
|
|
24
|
+
type: 'RESP_HEADER',
|
|
25
|
+
logEpoch: 'frame-probe',
|
|
26
|
+
resetRequired: false,
|
|
27
|
+
}
|
|
28
|
+
: { type: 'RESP_HEADER' };
|
|
22
29
|
const STUB_SUB_START: SubStartFrame = {
|
|
23
30
|
type: 'SUB_START',
|
|
24
31
|
id: '',
|
|
@@ -30,18 +37,28 @@ const STUB_SUB_START: SubStartFrame = {
|
|
|
30
37
|
const STUB_SUB_END: SubEndFrame = { type: 'SUB_END', nextCursor: 0 };
|
|
31
38
|
|
|
32
39
|
const probe = encodeMessage({
|
|
33
|
-
wireVersion:
|
|
40
|
+
wireVersion: 1,
|
|
34
41
|
msgKind: 'response',
|
|
35
|
-
frames: [
|
|
42
|
+
frames: [stubHeader(1)],
|
|
36
43
|
});
|
|
37
44
|
|
|
38
45
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
39
|
-
export
|
|
46
|
+
export function responseEnvelopeHeader(wireVersion: number): Uint8Array {
|
|
47
|
+
const encoded = encodeMessage({
|
|
48
|
+
wireVersion,
|
|
49
|
+
msgKind: 'response',
|
|
50
|
+
frames: [stubHeader(wireVersion)],
|
|
51
|
+
});
|
|
52
|
+
return encoded.slice(0, 8);
|
|
53
|
+
}
|
|
40
54
|
|
|
41
55
|
/** The terminating END frame (§1.2 rule 1). */
|
|
42
56
|
export const END_FRAME_BYTES: Uint8Array = probe.slice(probe.length - 5);
|
|
43
57
|
|
|
44
|
-
function wrapperFor(
|
|
58
|
+
function wrapperFor(
|
|
59
|
+
frame: ResponseFrame,
|
|
60
|
+
wireVersion: number,
|
|
61
|
+
): {
|
|
45
62
|
frames: ResponseFrame[];
|
|
46
63
|
index: number;
|
|
47
64
|
} {
|
|
@@ -50,11 +67,11 @@ function wrapperFor(frame: ResponseFrame): {
|
|
|
50
67
|
return { frames: [frame], index: 0 };
|
|
51
68
|
case 'LEASE':
|
|
52
69
|
// §7.3.2: LEASE rides immediately after RESP_HEADER.
|
|
53
|
-
return { frames: [
|
|
70
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
54
71
|
case 'PUSH_RESULT':
|
|
55
72
|
case 'ERROR':
|
|
56
73
|
case 'UNKNOWN':
|
|
57
|
-
return { frames: [
|
|
74
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
58
75
|
case 'PUSH_RESULT_DETAILS': {
|
|
59
76
|
const result: PushResultFrame = {
|
|
60
77
|
type: 'PUSH_RESULT',
|
|
@@ -68,17 +85,23 @@ function wrapperFor(frame: ResponseFrame): {
|
|
|
68
85
|
retryable: false,
|
|
69
86
|
})),
|
|
70
87
|
};
|
|
71
|
-
return { frames: [
|
|
88
|
+
return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
|
|
72
89
|
}
|
|
73
90
|
case 'SUB_START':
|
|
74
|
-
return {
|
|
91
|
+
return {
|
|
92
|
+
frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
|
|
93
|
+
index: 1,
|
|
94
|
+
};
|
|
75
95
|
case 'SUB_END':
|
|
76
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
|
|
98
|
+
index: 2,
|
|
99
|
+
};
|
|
77
100
|
case 'COMMIT':
|
|
78
101
|
case 'SEGMENT_REF':
|
|
79
102
|
case 'SEGMENT_INLINE':
|
|
80
103
|
return {
|
|
81
|
-
frames: [
|
|
104
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
|
|
82
105
|
index: 2,
|
|
83
106
|
};
|
|
84
107
|
}
|
|
@@ -88,10 +111,13 @@ function wrapperFor(frame: ResponseFrame): {
|
|
|
88
111
|
* Encode one response frame (5-byte frame header + payload) using the
|
|
89
112
|
* reference codec.
|
|
90
113
|
*/
|
|
91
|
-
export function encodeResponseFrame(
|
|
92
|
-
|
|
114
|
+
export function encodeResponseFrame(
|
|
115
|
+
frame: ResponseFrame,
|
|
116
|
+
wireVersion = PROTOCOL_WIRE_VERSION,
|
|
117
|
+
): Uint8Array {
|
|
118
|
+
const { frames, index } = wrapperFor(frame, wireVersion);
|
|
93
119
|
const encoded = encodeMessage({
|
|
94
|
-
wireVersion
|
|
120
|
+
wireVersion,
|
|
95
121
|
msgKind: 'response',
|
|
96
122
|
frames,
|
|
97
123
|
});
|
package/src/handler.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
limitsOf,
|
|
29
29
|
REMOTE_COMMAND_CLIENT_ID_PREFIX,
|
|
30
30
|
RESOLVER_OUTAGE,
|
|
31
|
+
touchAuthenticatedPartition,
|
|
31
32
|
} from './context';
|
|
32
33
|
import { SyncError, syncError } from './errors';
|
|
33
34
|
import {
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
import {
|
|
39
40
|
END_FRAME_BYTES,
|
|
40
41
|
encodeResponseFrame,
|
|
41
|
-
|
|
42
|
+
responseEnvelopeHeader,
|
|
42
43
|
} from './frame-bytes';
|
|
43
44
|
import type { LeaseRecord } from './lease-store';
|
|
44
45
|
import {
|
|
@@ -53,9 +54,12 @@ import { type ProcessedPushCommit, processPushCommitWithTrace } from './push';
|
|
|
53
54
|
import type { CompiledSchema } from './schema';
|
|
54
55
|
import { compileSchema } from './schema';
|
|
55
56
|
import { computeEffective, type ResolvedScopes } from './scopes';
|
|
56
|
-
import type { ClientSubscription } from './storage';
|
|
57
|
+
import type { ClientSubscription, PartitionRegistryEntry } from './storage';
|
|
57
58
|
|
|
58
59
|
interface RequestPlan {
|
|
60
|
+
readonly wireVersion: number;
|
|
61
|
+
readonly logEpoch: string;
|
|
62
|
+
readonly epochReset: boolean;
|
|
59
63
|
readonly header: ReqHeaderFrame;
|
|
60
64
|
readonly pushes: readonly PushCommitFrame[];
|
|
61
65
|
readonly pull: PullHeaderFrame | undefined;
|
|
@@ -192,6 +196,7 @@ async function planRequest(
|
|
|
192
196
|
request: RequestMessage,
|
|
193
197
|
ctx: SyncRequestContext,
|
|
194
198
|
schema: CompiledSchema,
|
|
199
|
+
registry: PartitionRegistryEntry,
|
|
195
200
|
): Promise<RequestPlan> {
|
|
196
201
|
const header = request.frames[0];
|
|
197
202
|
if (header === undefined || header.type !== 'REQ_HEADER') {
|
|
@@ -206,10 +211,32 @@ async function planRequest(
|
|
|
206
211
|
else if (frame.type === 'SUBSCRIPTION') subFrames.push(frame);
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
if (request.wireVersion === 1 && registry.epochRequired) {
|
|
215
|
+
throw syncError(
|
|
216
|
+
'sync.client_wire_unsupported',
|
|
217
|
+
'this partition requires a client that validates log epochs (§2.1)',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
request.wireVersion >= 2 &&
|
|
222
|
+
header.logEpoch === undefined &&
|
|
223
|
+
pushes.length > 0
|
|
224
|
+
) {
|
|
225
|
+
throw syncError(
|
|
226
|
+
'sync.invalid_request',
|
|
227
|
+
'epoch acquisition requests must not carry push commits (§2.1)',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const epochReset =
|
|
231
|
+
request.wireVersion >= 2 && header.logEpoch !== registry.logEpoch;
|
|
232
|
+
|
|
209
233
|
if (header.schemaVersion !== schema.version) {
|
|
210
234
|
// §2.4: no degraded encoding — answer with the schema floor (§1.6).
|
|
211
235
|
// No lease is issued on a floor round (§7.3.3).
|
|
212
236
|
return {
|
|
237
|
+
wireVersion: request.wireVersion,
|
|
238
|
+
logEpoch: registry.logEpoch,
|
|
239
|
+
epochReset,
|
|
213
240
|
header,
|
|
214
241
|
pushes,
|
|
215
242
|
pull,
|
|
@@ -220,6 +247,21 @@ async function planRequest(
|
|
|
220
247
|
};
|
|
221
248
|
}
|
|
222
249
|
|
|
250
|
+
if (epochReset) {
|
|
251
|
+
return {
|
|
252
|
+
wireVersion: request.wireVersion,
|
|
253
|
+
logEpoch: registry.logEpoch,
|
|
254
|
+
epochReset: true,
|
|
255
|
+
header,
|
|
256
|
+
pushes: [],
|
|
257
|
+
pull: undefined,
|
|
258
|
+
subscriptions: [],
|
|
259
|
+
resolved: { ok: false },
|
|
260
|
+
schemaFloor: false,
|
|
261
|
+
leaseToEmit: undefined,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
223
265
|
if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
224
266
|
throw syncError(
|
|
225
267
|
'sync.invalid_client_id',
|
|
@@ -313,6 +355,9 @@ async function planRequest(
|
|
|
313
355
|
});
|
|
314
356
|
|
|
315
357
|
return {
|
|
358
|
+
wireVersion: request.wireVersion,
|
|
359
|
+
logEpoch: registry.logEpoch,
|
|
360
|
+
epochReset: false,
|
|
316
361
|
header,
|
|
317
362
|
pushes,
|
|
318
363
|
pull,
|
|
@@ -325,7 +370,7 @@ async function planRequest(
|
|
|
325
370
|
|
|
326
371
|
/** Mutable outcome box shared with the instrumented stream wrapper. */
|
|
327
372
|
interface RequestReport {
|
|
328
|
-
outcome: 'ok' | 'schema_floor' | 'error';
|
|
373
|
+
outcome: 'ok' | 'schema_floor' | 'reset' | 'error';
|
|
329
374
|
errorCode?: string;
|
|
330
375
|
}
|
|
331
376
|
|
|
@@ -413,28 +458,48 @@ async function* streamResponse(
|
|
|
413
458
|
report?: RequestReport,
|
|
414
459
|
): AsyncGenerator<Uint8Array> {
|
|
415
460
|
const events = ctx.events;
|
|
416
|
-
yield
|
|
461
|
+
yield responseEnvelopeHeader(plan.wireVersion);
|
|
417
462
|
if (plan.schemaFloor) {
|
|
418
463
|
if (report !== undefined) report.outcome = 'schema_floor';
|
|
419
|
-
yield encodeResponseFrame(
|
|
464
|
+
yield encodeResponseFrame(
|
|
465
|
+
{
|
|
466
|
+
type: 'RESP_HEADER',
|
|
467
|
+
requiredSchemaVersion: schema.version,
|
|
468
|
+
latestSchemaVersion: schema.version,
|
|
469
|
+
...(plan.wireVersion >= 2
|
|
470
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
471
|
+
: {}),
|
|
472
|
+
},
|
|
473
|
+
plan.wireVersion,
|
|
474
|
+
);
|
|
475
|
+
yield END_FRAME_BYTES;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
yield encodeResponseFrame(
|
|
479
|
+
{
|
|
420
480
|
type: 'RESP_HEADER',
|
|
421
|
-
requiredSchemaVersion: schema.version,
|
|
422
481
|
latestSchemaVersion: schema.version,
|
|
423
|
-
|
|
482
|
+
...(plan.wireVersion >= 2
|
|
483
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
484
|
+
: {}),
|
|
485
|
+
},
|
|
486
|
+
plan.wireVersion,
|
|
487
|
+
);
|
|
488
|
+
if (plan.epochReset) {
|
|
489
|
+
if (report !== undefined) report.outcome = 'reset';
|
|
424
490
|
yield END_FRAME_BYTES;
|
|
425
491
|
return;
|
|
426
492
|
}
|
|
427
|
-
yield encodeResponseFrame({
|
|
428
|
-
type: 'RESP_HEADER',
|
|
429
|
-
latestSchemaVersion: schema.version,
|
|
430
|
-
});
|
|
431
493
|
// §7.3.2: the LEASE frame rides immediately after RESP_HEADER.
|
|
432
494
|
if (plan.leaseToEmit !== undefined) {
|
|
433
|
-
yield encodeResponseFrame(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
495
|
+
yield encodeResponseFrame(
|
|
496
|
+
{
|
|
497
|
+
type: 'LEASE',
|
|
498
|
+
leaseId: plan.leaseToEmit.leaseId,
|
|
499
|
+
expiresAtMs: plan.leaseToEmit.expiresAtMs,
|
|
500
|
+
},
|
|
501
|
+
plan.wireVersion,
|
|
502
|
+
);
|
|
438
503
|
if (events !== undefined) {
|
|
439
504
|
emitEvent(events, {
|
|
440
505
|
type: 'lease.issued',
|
|
@@ -461,9 +526,11 @@ async function* streamResponse(
|
|
|
461
526
|
if (events !== undefined) {
|
|
462
527
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
463
528
|
}
|
|
464
|
-
yield encodeResponseFrame(frame);
|
|
529
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
465
530
|
const details = pushResultDetailsFrame(frame);
|
|
466
|
-
if (details !== undefined)
|
|
531
|
+
if (details !== undefined) {
|
|
532
|
+
yield encodeResponseFrame(details, plan.wireVersion);
|
|
533
|
+
}
|
|
467
534
|
}
|
|
468
535
|
|
|
469
536
|
// Pull half (§4): subscriptions echoed in request order.
|
|
@@ -485,6 +552,7 @@ async function* streamResponse(
|
|
|
485
552
|
maxSeq,
|
|
486
553
|
horizonSeq,
|
|
487
554
|
trace,
|
|
555
|
+
plan.logEpoch,
|
|
488
556
|
);
|
|
489
557
|
let status: 'active' | 'revoked' | 'reset' = 'active';
|
|
490
558
|
let bootstrap = false;
|
|
@@ -502,7 +570,7 @@ async function* streamResponse(
|
|
|
502
570
|
changes += frame.changes.length;
|
|
503
571
|
}
|
|
504
572
|
}
|
|
505
|
-
yield encodeResponseFrame(frame);
|
|
573
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
506
574
|
step = await section.next();
|
|
507
575
|
}
|
|
508
576
|
if (step.value.active) cursors.push(step.value.nextCursor);
|
|
@@ -556,6 +624,7 @@ async function* streamResponse(
|
|
|
556
624
|
await ctx.storage.putClientRecord(ctx.partition, {
|
|
557
625
|
clientId: plan.header.clientId,
|
|
558
626
|
actorId: ctx.actorId,
|
|
627
|
+
wireVersion: plan.wireVersion,
|
|
559
628
|
cursor,
|
|
560
629
|
updatedAtMs: clockOf(ctx)(),
|
|
561
630
|
subscriptions,
|
|
@@ -567,15 +636,18 @@ async function* streamResponse(
|
|
|
567
636
|
report.errorCode = error.code;
|
|
568
637
|
}
|
|
569
638
|
// §1.6: in-band ERROR, then END, nothing else.
|
|
570
|
-
yield encodeResponseFrame(
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
639
|
+
yield encodeResponseFrame(
|
|
640
|
+
{
|
|
641
|
+
type: 'ERROR',
|
|
642
|
+
code: error.code,
|
|
643
|
+
message: error.message,
|
|
644
|
+
category: error.category,
|
|
645
|
+
retryable: error.retryable,
|
|
646
|
+
recommendedAction: error.recommendedAction,
|
|
647
|
+
...(error.details !== undefined ? { details: error.details } : {}),
|
|
648
|
+
},
|
|
649
|
+
plan.wireVersion,
|
|
650
|
+
);
|
|
579
651
|
yield END_FRAME_BYTES;
|
|
580
652
|
return;
|
|
581
653
|
}
|
|
@@ -644,7 +716,8 @@ async function createStreamCore(
|
|
|
644
716
|
// Relational row tables: create/
|
|
645
717
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
646
718
|
await ctx.storage.ensureSchema(schema);
|
|
647
|
-
const
|
|
719
|
+
const registry = await touchAuthenticatedPartition(ctx);
|
|
720
|
+
const plan = await planRequest(request, ctx, schema, registry);
|
|
648
721
|
if (events === undefined) return streamResponse(plan, ctx, schema);
|
|
649
722
|
const report: RequestReport = { outcome: 'ok' };
|
|
650
723
|
return instrumentedStream(
|
package/src/index-bun.ts
ADDED
package/src/index.ts
CHANGED
|
@@ -64,6 +64,7 @@ export {
|
|
|
64
64
|
type ReactionTypeMap,
|
|
65
65
|
} from './reactions';
|
|
66
66
|
export * from './realtime';
|
|
67
|
+
export * from './restore';
|
|
67
68
|
export * from './relational-rows';
|
|
68
69
|
export * from './s3-blob-store';
|
|
69
70
|
export * from './s3-segment-store';
|
|
@@ -73,13 +74,15 @@ export * from './seed';
|
|
|
73
74
|
export * from './segment-download';
|
|
74
75
|
export * from './segment-store';
|
|
75
76
|
export * from './signed-url';
|
|
76
|
-
// Bun-specific storages (top-level `bun:sqlite`): re-exported for Bun/Node
|
|
77
|
-
// hosts. Workers/edge builds tree-shake them (and their `bun:sqlite` import)
|
|
78
|
-
// away — the runtime-neutral core closure is enforced by
|
|
79
|
-
// `test/runtime-neutrality.test.ts`.
|
|
80
|
-
export * from './sqlite-blob-store';
|
|
81
77
|
export * from './sqlite-dialect';
|
|
82
|
-
export * from './sqlite-
|
|
78
|
+
export * from './sqlite-driver';
|
|
79
|
+
export {
|
|
80
|
+
IMAGE_METADATA_TABLE,
|
|
81
|
+
IMAGE_VERSION_COLUMN,
|
|
82
|
+
type SqliteImageBuilder,
|
|
83
|
+
type SqliteImageInput,
|
|
84
|
+
} from './sqlite-image';
|
|
85
|
+
export * from './sqlite-blob-store';
|
|
83
86
|
export * from './sqlite-lease-store';
|
|
84
87
|
export * from './sqlite-segment-store';
|
|
85
88
|
export * from './sqlite-storage';
|
package/src/operations.ts
CHANGED
|
@@ -9,7 +9,11 @@ import {
|
|
|
9
9
|
type ScopeMap,
|
|
10
10
|
} from '@syncular/core';
|
|
11
11
|
import type { SyncRequestContext } from './context';
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
REMOTE_COMMAND_CLIENT_ID_PREFIX,
|
|
14
|
+
RESOLVER_OUTAGE,
|
|
15
|
+
touchAuthenticatedPartition,
|
|
16
|
+
} from './context';
|
|
13
17
|
import { SyncError, syncError } from './errors';
|
|
14
18
|
import { processPushOperationsWithTrace } from './push';
|
|
15
19
|
import { compileSchema } from './schema';
|
|
@@ -673,6 +677,7 @@ export async function handleRemoteOperation(
|
|
|
673
677
|
return encodeRemoteOperationError(error, 'operation.invalid_request');
|
|
674
678
|
}
|
|
675
679
|
try {
|
|
680
|
+
await touchAuthenticatedPartition(ctx);
|
|
676
681
|
if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
677
682
|
throw syncError(
|
|
678
683
|
'sync.invalid_client_id',
|