@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/dist/d1-storage.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
var _a;
|
|
2
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
3
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
2
4
|
/**
|
|
3
5
|
* Cloudflare D1 server storage for Workers deployments.
|
|
4
6
|
*
|
|
@@ -479,6 +481,12 @@ export class D1ServerStorage {
|
|
|
479
481
|
for (const statement of sqliteDdlStatements()) {
|
|
480
482
|
await this.#db.exec(`${statement.replace(/\s+/g, ' ')};`);
|
|
481
483
|
}
|
|
484
|
+
const { results } = await this.#db
|
|
485
|
+
.prepare('PRAGMA table_info("sync_clients")')
|
|
486
|
+
.all();
|
|
487
|
+
if (!results.some((column) => column.name === 'wire_version')) {
|
|
488
|
+
await this.#db.exec('ALTER TABLE sync_clients ADD COLUMN wire_version INTEGER NOT NULL DEFAULT 1;');
|
|
489
|
+
}
|
|
482
490
|
}
|
|
483
491
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
484
492
|
table(name) {
|
|
@@ -563,6 +571,68 @@ export class D1ServerStorage {
|
|
|
563
571
|
this.#tables = schema.tables;
|
|
564
572
|
this.#schemaVersion = schema.version;
|
|
565
573
|
}
|
|
574
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
575
|
+
if (initialLogEpoch.length === 0) {
|
|
576
|
+
throw new Error('initial log epoch must be non-empty');
|
|
577
|
+
}
|
|
578
|
+
await this.#db
|
|
579
|
+
.prepare(`INSERT INTO sync_partition_registry(
|
|
580
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
581
|
+
) VALUES (?,?,?)
|
|
582
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
583
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
584
|
+
.bind(partition, initialLogEpoch, authenticatedAtMs)
|
|
585
|
+
.run();
|
|
586
|
+
const row = await this.#db
|
|
587
|
+
.prepare(`SELECT log_epoch, epoch_required, last_authenticated_at_ms
|
|
588
|
+
FROM sync_partition_registry WHERE partition=?`)
|
|
589
|
+
.bind(partition)
|
|
590
|
+
.first();
|
|
591
|
+
if (row === null)
|
|
592
|
+
throw new Error('partition registry write did not persist');
|
|
593
|
+
return {
|
|
594
|
+
partition,
|
|
595
|
+
logEpoch: row.log_epoch,
|
|
596
|
+
epochRequired: row.epoch_required === 1,
|
|
597
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
601
|
+
if (logEpoch.length === 0)
|
|
602
|
+
throw new Error('log epoch must be non-empty');
|
|
603
|
+
await this.#db.batch([
|
|
604
|
+
this.#db
|
|
605
|
+
.prepare(`INSERT INTO sync_partition_registry(
|
|
606
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
607
|
+
) VALUES (?,?,1,?)
|
|
608
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
609
|
+
log_epoch=excluded.log_epoch,
|
|
610
|
+
epoch_required=1,
|
|
611
|
+
last_authenticated_at_ms=excluded.last_authenticated_at_ms`)
|
|
612
|
+
.bind(partition, logEpoch, authenticatedAtMs),
|
|
613
|
+
this.#db
|
|
614
|
+
.prepare('DELETE FROM sync_clients WHERE partition=?')
|
|
615
|
+
.bind(partition),
|
|
616
|
+
]);
|
|
617
|
+
return {
|
|
618
|
+
partition,
|
|
619
|
+
logEpoch,
|
|
620
|
+
epochRequired: true,
|
|
621
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
async listPartitionRegistry() {
|
|
625
|
+
const { results } = await this.#db
|
|
626
|
+
.prepare(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
627
|
+
FROM sync_partition_registry ORDER BY partition`)
|
|
628
|
+
.all();
|
|
629
|
+
return results.map((row) => ({
|
|
630
|
+
partition: row.partition,
|
|
631
|
+
logEpoch: row.log_epoch,
|
|
632
|
+
epochRequired: row.epoch_required === 1,
|
|
633
|
+
lastAuthenticatedAtMs: row.last_authenticated_at_ms,
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
566
636
|
/** Keyset-paged migration rewrite (see the sqlite storage's counterpart). */
|
|
567
637
|
async #rewriteRows(table, oldLayout) {
|
|
568
638
|
const select = selectRowsForRewriteSql(table, 'sqlite');
|
|
@@ -608,7 +678,7 @@ export class D1ServerStorage {
|
|
|
608
678
|
if (this.#tables === undefined) {
|
|
609
679
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
610
680
|
}
|
|
611
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
681
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
612
682
|
const results = await this.#db.batch([
|
|
613
683
|
this.#db.prepare(prepared.sql).bind(...prepared.params),
|
|
614
684
|
this.#db
|
|
@@ -639,6 +709,13 @@ export class D1ServerStorage {
|
|
|
639
709
|
maxCommitSeq,
|
|
640
710
|
};
|
|
641
711
|
}
|
|
712
|
+
async getPartitionLogEpoch(partition) {
|
|
713
|
+
const row = await this.#db
|
|
714
|
+
.prepare('SELECT log_epoch FROM sync_partition_registry WHERE partition=?')
|
|
715
|
+
.bind(partition)
|
|
716
|
+
.first();
|
|
717
|
+
return row?.log_epoch;
|
|
718
|
+
}
|
|
642
719
|
async getHorizonSeq(partition) {
|
|
643
720
|
const row = await this.#db
|
|
644
721
|
.prepare('SELECT horizon_seq FROM sync_partitions WHERE partition=?')
|
|
@@ -648,27 +725,55 @@ export class D1ServerStorage {
|
|
|
648
725
|
}
|
|
649
726
|
async setHorizonSeq(partition, seq) {
|
|
650
727
|
await this.#db
|
|
651
|
-
.prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=excluded.horizon_seq')
|
|
728
|
+
.prepare('INSERT INTO sync_partitions(partition, horizon_seq) VALUES (?,?) ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)')
|
|
652
729
|
.bind(partition, seq)
|
|
653
730
|
.run();
|
|
654
731
|
}
|
|
655
|
-
async pruneCommitsThrough(partition,
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
732
|
+
async pruneCommitsThrough(partition, query) {
|
|
733
|
+
validateCommitPruneQuery(query);
|
|
734
|
+
if (!this.#pushApplySerialized)
|
|
735
|
+
throw new Error('D1 pruning requires externally serialized partition writes');
|
|
736
|
+
const epochGuard = 'EXISTS (SELECT 1 FROM sync_partition_registry WHERE partition=? AND log_epoch=?)';
|
|
737
|
+
const horizon = '(SELECT horizon_seq FROM sync_partitions WHERE partition=?)';
|
|
738
|
+
const results = await this.#db.batch([
|
|
661
739
|
this.#db
|
|
662
|
-
.prepare(
|
|
663
|
-
.bind(partition,
|
|
740
|
+
.prepare(`SELECT log_epoch, coalesce(${horizon},0) AS previous_horizon_seq FROM sync_partition_registry WHERE partition=?`)
|
|
741
|
+
.bind(partition, partition),
|
|
664
742
|
this.#db
|
|
665
|
-
.prepare(
|
|
666
|
-
|
|
743
|
+
.prepare(`INSERT INTO sync_partitions(partition,horizon_seq) SELECT ?,? WHERE ${epochGuard}
|
|
744
|
+
ON CONFLICT(partition) DO UPDATE SET horizon_seq=max(horizon_seq,excluded.horizon_seq)`)
|
|
745
|
+
.bind(partition, query.throughSeq, partition, query.logEpoch),
|
|
667
746
|
this.#db
|
|
668
|
-
.prepare(
|
|
669
|
-
.bind(partition,
|
|
747
|
+
.prepare(`SELECT horizon_seq, (SELECT count(*) FROM sync_commits WHERE partition=? AND commit_seq<=${horizon}) AS removed_commits FROM sync_partitions WHERE partition=?`)
|
|
748
|
+
.bind(partition, partition, partition),
|
|
749
|
+
...['sync_commits', 'sync_changes', 'sync_change_scopes'].map((table) => this.#db
|
|
750
|
+
.prepare(`DELETE FROM ${table} WHERE partition=? AND commit_seq<=${horizon} AND ${epochGuard}`)
|
|
751
|
+
.bind(partition, partition, partition, query.logEpoch)),
|
|
670
752
|
]);
|
|
671
|
-
|
|
753
|
+
const [before, after] = [results[0], results[2]].map((result) => {
|
|
754
|
+
if (typeof result !== 'object' ||
|
|
755
|
+
result === null ||
|
|
756
|
+
!('results' in result) ||
|
|
757
|
+
!Array.isArray(result.results)) {
|
|
758
|
+
throw new Error('D1 pruning returned an invalid batch result');
|
|
759
|
+
}
|
|
760
|
+
const row = result.results[0];
|
|
761
|
+
return typeof row === 'object' && row !== null
|
|
762
|
+
? row
|
|
763
|
+
: undefined;
|
|
764
|
+
});
|
|
765
|
+
if (before?.log_epoch !== query.logEpoch)
|
|
766
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
767
|
+
if (typeof before.previous_horizon_seq !== 'number' ||
|
|
768
|
+
typeof after?.horizon_seq !== 'number' ||
|
|
769
|
+
typeof after.removed_commits !== 'number') {
|
|
770
|
+
throw new Error('D1 pruning returned invalid horizon metadata');
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
previousHorizonSeq: before.previous_horizon_seq,
|
|
774
|
+
horizonSeq: after.horizon_seq,
|
|
775
|
+
removedCommits: after.removed_commits,
|
|
776
|
+
};
|
|
672
777
|
}
|
|
673
778
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
674
779
|
const row = await this.#db
|
|
@@ -912,7 +1017,7 @@ export class D1ServerStorage {
|
|
|
912
1017
|
}
|
|
913
1018
|
async getClientRecord(partition, clientId) {
|
|
914
1019
|
const record = await this.#db
|
|
915
|
-
.prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
1020
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? AND client_id=?')
|
|
916
1021
|
.bind(partition, clientId)
|
|
917
1022
|
.first();
|
|
918
1023
|
if (record === null)
|
|
@@ -920,6 +1025,7 @@ export class D1ServerStorage {
|
|
|
920
1025
|
return {
|
|
921
1026
|
clientId: record.client_id,
|
|
922
1027
|
actorId: record.actor_id,
|
|
1028
|
+
wireVersion: record.wire_version,
|
|
923
1029
|
cursor: record.cursor,
|
|
924
1030
|
updatedAtMs: record.updated_at_ms,
|
|
925
1031
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -927,10 +1033,17 @@ export class D1ServerStorage {
|
|
|
927
1033
|
}
|
|
928
1034
|
async putClientRecord(partition, record) {
|
|
929
1035
|
await this.#db
|
|
930
|
-
.prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms) VALUES (
|
|
931
|
-
.bind(partition, record.clientId, record.actorId, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
1036
|
+
.prepare('INSERT OR REPLACE INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms) VALUES (?,?,?,?,?,?,?)')
|
|
1037
|
+
.bind(partition, record.clientId, record.actorId, record.wireVersion, record.cursor, JSON.stringify(record.subscriptions), record.updatedAtMs)
|
|
932
1038
|
.run();
|
|
933
1039
|
}
|
|
1040
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1041
|
+
const row = await this.#db
|
|
1042
|
+
.prepare('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=? AND updated_at_ms>=?')
|
|
1043
|
+
.bind(partition, cutoffMs)
|
|
1044
|
+
.first();
|
|
1045
|
+
return row.cursor;
|
|
1046
|
+
}
|
|
934
1047
|
async listClientCursors(partition) {
|
|
935
1048
|
const { results } = await this.#db
|
|
936
1049
|
.prepare('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=?')
|
|
@@ -976,12 +1089,13 @@ export class D1ServerStorage {
|
|
|
976
1089
|
// -- admin/console read surface --------------------------------------------
|
|
977
1090
|
async listClientRecords(partition) {
|
|
978
1091
|
const { results } = await this.#db
|
|
979
|
-
.prepare('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
1092
|
+
.prepare('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=? ORDER BY updated_at_ms DESC')
|
|
980
1093
|
.bind(partition)
|
|
981
1094
|
.all();
|
|
982
1095
|
return results.map((record) => ({
|
|
983
1096
|
clientId: record.client_id,
|
|
984
1097
|
actorId: record.actor_id,
|
|
1098
|
+
wireVersion: record.wire_version,
|
|
985
1099
|
cursor: record.cursor,
|
|
986
1100
|
updatedAtMs: record.updated_at_ms,
|
|
987
1101
|
subscriptions: JSON.parse(record.subscriptions),
|
|
@@ -1065,12 +1179,6 @@ export class D1ServerStorage {
|
|
|
1065
1179
|
};
|
|
1066
1180
|
}
|
|
1067
1181
|
async listPartitions() {
|
|
1068
|
-
|
|
1069
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1070
|
-
const { results } = await this.#db
|
|
1071
|
-
.prepare(`SELECT partition FROM sync_partitions
|
|
1072
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`)
|
|
1073
|
-
.all();
|
|
1074
|
-
return results.map((r) => r.partition);
|
|
1182
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1075
1183
|
}
|
|
1076
1184
|
}
|
package/dist/errors.js
CHANGED
|
@@ -191,6 +191,12 @@ export const ERROR_CATALOG = {
|
|
|
191
191
|
recommendedAction: 'upgradeClient',
|
|
192
192
|
httpStatus: 400,
|
|
193
193
|
},
|
|
194
|
+
'sync.client_wire_unsupported': {
|
|
195
|
+
category: 'schema-mismatch',
|
|
196
|
+
retryable: false,
|
|
197
|
+
recommendedAction: 'upgradeClient',
|
|
198
|
+
httpStatus: 400,
|
|
199
|
+
},
|
|
194
200
|
'sync.websocket_connection_limit': {
|
|
195
201
|
category: 'rate-limited',
|
|
196
202
|
retryable: true,
|
package/dist/events.d.ts
CHANGED
|
@@ -28,10 +28,11 @@ export interface RequestHandledEvent {
|
|
|
28
28
|
/**
|
|
29
29
|
* `ok` — response streamed to END;
|
|
30
30
|
* `schema_floor` — §2.4 required-schema answer;
|
|
31
|
+
* `reset` — §2.1 log-epoch reset answer;
|
|
31
32
|
* `rejected` — request validation failed before any bytes (§1.7);
|
|
32
33
|
* `error` — in-band ERROR frame (§1.6) or a thrown host failure.
|
|
33
34
|
*/
|
|
34
|
-
readonly outcome: 'ok' | 'schema_floor' | 'rejected' | 'error';
|
|
35
|
+
readonly outcome: 'ok' | 'schema_floor' | 'reset' | 'rejected' | 'error';
|
|
35
36
|
/** §10.2 code for `rejected`/`error`; `"internal"` for non-SyncErrors. */
|
|
36
37
|
readonly errorCode?: string;
|
|
37
38
|
readonly pushCommits: number;
|
package/dist/frame-bytes.d.ts
CHANGED
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { type ResponseFrame } from '@syncular/core';
|
|
12
12
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
13
|
-
export declare
|
|
13
|
+
export declare function responseEnvelopeHeader(wireVersion: number): Uint8Array;
|
|
14
14
|
/** The terminating END frame (§1.2 rule 1). */
|
|
15
15
|
export declare const END_FRAME_BYTES: Uint8Array;
|
|
16
16
|
/**
|
|
17
17
|
* Encode one response frame (5-byte frame header + payload) using the
|
|
18
18
|
* reference codec.
|
|
19
19
|
*/
|
|
20
|
-
export declare function encodeResponseFrame(frame: ResponseFrame): Uint8Array;
|
|
20
|
+
export declare function encodeResponseFrame(frame: ResponseFrame, wireVersion?: number): Uint8Array;
|
package/dist/frame-bytes.js
CHANGED
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
* bytes; the reference codec remains the single source of wire bytes.
|
|
10
10
|
*/
|
|
11
11
|
import { encodeMessage, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
|
|
12
|
-
const
|
|
12
|
+
const stubHeader = (wireVersion) => wireVersion >= 2
|
|
13
|
+
? {
|
|
14
|
+
type: 'RESP_HEADER',
|
|
15
|
+
logEpoch: 'frame-probe',
|
|
16
|
+
resetRequired: false,
|
|
17
|
+
}
|
|
18
|
+
: { type: 'RESP_HEADER' };
|
|
13
19
|
const STUB_SUB_START = {
|
|
14
20
|
type: 'SUB_START',
|
|
15
21
|
id: '',
|
|
@@ -20,25 +26,32 @@ const STUB_SUB_START = {
|
|
|
20
26
|
};
|
|
21
27
|
const STUB_SUB_END = { type: 'SUB_END', nextCursor: 0 };
|
|
22
28
|
const probe = encodeMessage({
|
|
23
|
-
wireVersion:
|
|
29
|
+
wireVersion: 1,
|
|
24
30
|
msgKind: 'response',
|
|
25
|
-
frames: [
|
|
31
|
+
frames: [stubHeader(1)],
|
|
26
32
|
});
|
|
27
33
|
/** The 8-byte SSP2 response envelope header (§1.2). */
|
|
28
|
-
export
|
|
34
|
+
export function responseEnvelopeHeader(wireVersion) {
|
|
35
|
+
const encoded = encodeMessage({
|
|
36
|
+
wireVersion,
|
|
37
|
+
msgKind: 'response',
|
|
38
|
+
frames: [stubHeader(wireVersion)],
|
|
39
|
+
});
|
|
40
|
+
return encoded.slice(0, 8);
|
|
41
|
+
}
|
|
29
42
|
/** The terminating END frame (§1.2 rule 1). */
|
|
30
43
|
export const END_FRAME_BYTES = probe.slice(probe.length - 5);
|
|
31
|
-
function wrapperFor(frame) {
|
|
44
|
+
function wrapperFor(frame, wireVersion) {
|
|
32
45
|
switch (frame.type) {
|
|
33
46
|
case 'RESP_HEADER':
|
|
34
47
|
return { frames: [frame], index: 0 };
|
|
35
48
|
case 'LEASE':
|
|
36
49
|
// §7.3.2: LEASE rides immediately after RESP_HEADER.
|
|
37
|
-
return { frames: [
|
|
50
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
38
51
|
case 'PUSH_RESULT':
|
|
39
52
|
case 'ERROR':
|
|
40
53
|
case 'UNKNOWN':
|
|
41
|
-
return { frames: [
|
|
54
|
+
return { frames: [stubHeader(wireVersion), frame], index: 1 };
|
|
42
55
|
case 'PUSH_RESULT_DETAILS': {
|
|
43
56
|
const result = {
|
|
44
57
|
type: 'PUSH_RESULT',
|
|
@@ -52,17 +65,23 @@ function wrapperFor(frame) {
|
|
|
52
65
|
retryable: false,
|
|
53
66
|
})),
|
|
54
67
|
};
|
|
55
|
-
return { frames: [
|
|
68
|
+
return { frames: [stubHeader(wireVersion), result, frame], index: 2 };
|
|
56
69
|
}
|
|
57
70
|
case 'SUB_START':
|
|
58
|
-
return {
|
|
71
|
+
return {
|
|
72
|
+
frames: [stubHeader(wireVersion), frame, STUB_SUB_END],
|
|
73
|
+
index: 1,
|
|
74
|
+
};
|
|
59
75
|
case 'SUB_END':
|
|
60
|
-
return {
|
|
76
|
+
return {
|
|
77
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame],
|
|
78
|
+
index: 2,
|
|
79
|
+
};
|
|
61
80
|
case 'COMMIT':
|
|
62
81
|
case 'SEGMENT_REF':
|
|
63
82
|
case 'SEGMENT_INLINE':
|
|
64
83
|
return {
|
|
65
|
-
frames: [
|
|
84
|
+
frames: [stubHeader(wireVersion), STUB_SUB_START, frame, STUB_SUB_END],
|
|
66
85
|
index: 2,
|
|
67
86
|
};
|
|
68
87
|
}
|
|
@@ -71,10 +90,10 @@ function wrapperFor(frame) {
|
|
|
71
90
|
* Encode one response frame (5-byte frame header + payload) using the
|
|
72
91
|
* reference codec.
|
|
73
92
|
*/
|
|
74
|
-
export function encodeResponseFrame(frame) {
|
|
75
|
-
const { frames, index } = wrapperFor(frame);
|
|
93
|
+
export function encodeResponseFrame(frame, wireVersion = PROTOCOL_WIRE_VERSION) {
|
|
94
|
+
const { frames, index } = wrapperFor(frame, wireVersion);
|
|
76
95
|
const encoded = encodeMessage({
|
|
77
|
-
wireVersion
|
|
96
|
+
wireVersion,
|
|
78
97
|
msgKind: 'response',
|
|
79
98
|
frames,
|
|
80
99
|
});
|
package/dist/handler.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* frame (§1.6).
|
|
11
11
|
*/
|
|
12
12
|
import { DecodeError, decodeMessage, } from '@syncular/core';
|
|
13
|
-
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, } from './context.js';
|
|
13
|
+
import { clockOf, limitsOf, REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
14
14
|
import { SyncError, syncError } from './errors.js';
|
|
15
15
|
import { emitEvent, } from './events.js';
|
|
16
|
-
import { END_FRAME_BYTES, encodeResponseFrame,
|
|
16
|
+
import { END_FRAME_BYTES, encodeResponseFrame, responseEnvelopeHeader, } from './frame-bytes.js';
|
|
17
17
|
import { ACCEPT_EXTERNAL_ROWS, ACCEPT_INLINE_ROWS, clampPullLimits, subscriptionSection, } from './pull.js';
|
|
18
18
|
import { processPushCommitWithTrace } from './push.js';
|
|
19
19
|
import { compileSchema } from './schema.js';
|
|
@@ -96,7 +96,7 @@ async function resolveOnce(ctx, clientId, schema) {
|
|
|
96
96
|
}
|
|
97
97
|
return { resolved: { ok: true, allowed }, leaseToEmit };
|
|
98
98
|
}
|
|
99
|
-
async function planRequest(request, ctx, schema) {
|
|
99
|
+
async function planRequest(request, ctx, schema, registry) {
|
|
100
100
|
const header = request.frames[0];
|
|
101
101
|
if (header === undefined || header.type !== 'REQ_HEADER') {
|
|
102
102
|
throw syncError('sync.invalid_request', 'missing REQ_HEADER');
|
|
@@ -112,10 +112,22 @@ async function planRequest(request, ctx, schema) {
|
|
|
112
112
|
else if (frame.type === 'SUBSCRIPTION')
|
|
113
113
|
subFrames.push(frame);
|
|
114
114
|
}
|
|
115
|
+
if (request.wireVersion === 1 && registry.epochRequired) {
|
|
116
|
+
throw syncError('sync.client_wire_unsupported', 'this partition requires a client that validates log epochs (§2.1)');
|
|
117
|
+
}
|
|
118
|
+
if (request.wireVersion >= 2 &&
|
|
119
|
+
header.logEpoch === undefined &&
|
|
120
|
+
pushes.length > 0) {
|
|
121
|
+
throw syncError('sync.invalid_request', 'epoch acquisition requests must not carry push commits (§2.1)');
|
|
122
|
+
}
|
|
123
|
+
const epochReset = request.wireVersion >= 2 && header.logEpoch !== registry.logEpoch;
|
|
115
124
|
if (header.schemaVersion !== schema.version) {
|
|
116
125
|
// §2.4: no degraded encoding — answer with the schema floor (§1.6).
|
|
117
126
|
// No lease is issued on a floor round (§7.3.3).
|
|
118
127
|
return {
|
|
128
|
+
wireVersion: request.wireVersion,
|
|
129
|
+
logEpoch: registry.logEpoch,
|
|
130
|
+
epochReset,
|
|
119
131
|
header,
|
|
120
132
|
pushes,
|
|
121
133
|
pull,
|
|
@@ -125,6 +137,20 @@ async function planRequest(request, ctx, schema) {
|
|
|
125
137
|
leaseToEmit: undefined,
|
|
126
138
|
};
|
|
127
139
|
}
|
|
140
|
+
if (epochReset) {
|
|
141
|
+
return {
|
|
142
|
+
wireVersion: request.wireVersion,
|
|
143
|
+
logEpoch: registry.logEpoch,
|
|
144
|
+
epochReset: true,
|
|
145
|
+
header,
|
|
146
|
+
pushes: [],
|
|
147
|
+
pull: undefined,
|
|
148
|
+
subscriptions: [],
|
|
149
|
+
resolved: { ok: false },
|
|
150
|
+
schemaFloor: false,
|
|
151
|
+
leaseToEmit: undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
128
154
|
if (header.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
129
155
|
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
130
156
|
}
|
|
@@ -181,6 +207,9 @@ async function planRequest(request, ctx, schema) {
|
|
|
181
207
|
return { frame, table, status: 'revoked', effective: {} };
|
|
182
208
|
});
|
|
183
209
|
return {
|
|
210
|
+
wireVersion: request.wireVersion,
|
|
211
|
+
logEpoch: registry.logEpoch,
|
|
212
|
+
epochReset: false,
|
|
184
213
|
header,
|
|
185
214
|
pushes,
|
|
186
215
|
pull,
|
|
@@ -256,7 +285,7 @@ function emitPushEvent(events, ctx, clientId, push, processed) {
|
|
|
256
285
|
}
|
|
257
286
|
async function* streamResponse(plan, ctx, schema, report) {
|
|
258
287
|
const events = ctx.events;
|
|
259
|
-
yield
|
|
288
|
+
yield responseEnvelopeHeader(plan.wireVersion);
|
|
260
289
|
if (plan.schemaFloor) {
|
|
261
290
|
if (report !== undefined)
|
|
262
291
|
report.outcome = 'schema_floor';
|
|
@@ -264,21 +293,33 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
264
293
|
type: 'RESP_HEADER',
|
|
265
294
|
requiredSchemaVersion: schema.version,
|
|
266
295
|
latestSchemaVersion: schema.version,
|
|
267
|
-
|
|
296
|
+
...(plan.wireVersion >= 2
|
|
297
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
298
|
+
: {}),
|
|
299
|
+
}, plan.wireVersion);
|
|
268
300
|
yield END_FRAME_BYTES;
|
|
269
301
|
return;
|
|
270
302
|
}
|
|
271
303
|
yield encodeResponseFrame({
|
|
272
304
|
type: 'RESP_HEADER',
|
|
273
305
|
latestSchemaVersion: schema.version,
|
|
274
|
-
|
|
306
|
+
...(plan.wireVersion >= 2
|
|
307
|
+
? { logEpoch: plan.logEpoch, resetRequired: plan.epochReset }
|
|
308
|
+
: {}),
|
|
309
|
+
}, plan.wireVersion);
|
|
310
|
+
if (plan.epochReset) {
|
|
311
|
+
if (report !== undefined)
|
|
312
|
+
report.outcome = 'reset';
|
|
313
|
+
yield END_FRAME_BYTES;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
275
316
|
// §7.3.2: the LEASE frame rides immediately after RESP_HEADER.
|
|
276
317
|
if (plan.leaseToEmit !== undefined) {
|
|
277
318
|
yield encodeResponseFrame({
|
|
278
319
|
type: 'LEASE',
|
|
279
320
|
leaseId: plan.leaseToEmit.leaseId,
|
|
280
321
|
expiresAtMs: plan.leaseToEmit.expiresAtMs,
|
|
281
|
-
});
|
|
322
|
+
}, plan.wireVersion);
|
|
282
323
|
if (events !== undefined) {
|
|
283
324
|
emitEvent(events, {
|
|
284
325
|
type: 'lease.issued',
|
|
@@ -299,10 +340,11 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
299
340
|
if (events !== undefined) {
|
|
300
341
|
emitPushEvent(events, ctx, plan.header.clientId, push, processed);
|
|
301
342
|
}
|
|
302
|
-
yield encodeResponseFrame(frame);
|
|
343
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
303
344
|
const details = pushResultDetailsFrame(frame);
|
|
304
|
-
if (details !== undefined)
|
|
305
|
-
yield encodeResponseFrame(details);
|
|
345
|
+
if (details !== undefined) {
|
|
346
|
+
yield encodeResponseFrame(details, plan.wireVersion);
|
|
347
|
+
}
|
|
306
348
|
}
|
|
307
349
|
// Pull half (§4): subscriptions echoed in request order.
|
|
308
350
|
const cursors = [];
|
|
@@ -313,7 +355,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
313
355
|
const horizonSeq = await ctx.storage.getHorizonSeq(ctx.partition);
|
|
314
356
|
for (const subscription of plan.subscriptions) {
|
|
315
357
|
const trace = summaries !== undefined ? { segments: [] } : undefined;
|
|
316
|
-
const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace);
|
|
358
|
+
const section = subscriptionSection(ctx, schema, limits, subscription, maxSeq, horizonSeq, trace, plan.logEpoch);
|
|
317
359
|
let status = 'active';
|
|
318
360
|
let bootstrap = false;
|
|
319
361
|
let commits = 0;
|
|
@@ -331,7 +373,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
331
373
|
changes += frame.changes.length;
|
|
332
374
|
}
|
|
333
375
|
}
|
|
334
|
-
yield encodeResponseFrame(frame);
|
|
376
|
+
yield encodeResponseFrame(frame, plan.wireVersion);
|
|
335
377
|
step = await section.next();
|
|
336
378
|
}
|
|
337
379
|
if (step.value.active)
|
|
@@ -379,6 +421,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
379
421
|
await ctx.storage.putClientRecord(ctx.partition, {
|
|
380
422
|
clientId: plan.header.clientId,
|
|
381
423
|
actorId: ctx.actorId,
|
|
424
|
+
wireVersion: plan.wireVersion,
|
|
382
425
|
cursor,
|
|
383
426
|
updatedAtMs: clockOf(ctx)(),
|
|
384
427
|
subscriptions,
|
|
@@ -399,7 +442,7 @@ async function* streamResponse(plan, ctx, schema, report) {
|
|
|
399
442
|
retryable: error.retryable,
|
|
400
443
|
recommendedAction: error.recommendedAction,
|
|
401
444
|
...(error.details !== undefined ? { details: error.details } : {}),
|
|
402
|
-
});
|
|
445
|
+
}, plan.wireVersion);
|
|
403
446
|
yield END_FRAME_BYTES;
|
|
404
447
|
return;
|
|
405
448
|
}
|
|
@@ -461,7 +504,8 @@ async function createStreamCore(bytes, ctx, events, startedAtMs = 0) {
|
|
|
461
504
|
// Relational row tables: create/
|
|
462
505
|
// migrate on first contact; memoized per storage instance thereafter.
|
|
463
506
|
await ctx.storage.ensureSchema(schema);
|
|
464
|
-
const
|
|
507
|
+
const registry = await touchAuthenticatedPartition(ctx);
|
|
508
|
+
const plan = await planRequest(request, ctx, schema, registry);
|
|
465
509
|
if (events === undefined)
|
|
466
510
|
return streamResponse(plan, ctx, schema);
|
|
467
511
|
const report = { outcome: 'ok' };
|
package/dist/index.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export * from './push.js';
|
|
|
32
32
|
export * from './readiness.js';
|
|
33
33
|
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, type PlannedReaction, type PruneReactionsOptions, type ReactionHandler, type ReactionHandlerInput, type ReactionHandlers, type ReactionPlan, type ReactionPlanner, type ReactionPlannerInput, type ReactionPruneResult, type ReactionRetentionPolicy, type ReactionRunnerOptions, type ReactionRunResult, type ReactionTypeMap, } from './reactions.js';
|
|
34
34
|
export * from './realtime.js';
|
|
35
|
+
export * from './restore.js';
|
|
35
36
|
export * from './relational-rows.js';
|
|
36
37
|
export * from './s3-blob-store.js';
|
|
37
38
|
export * from './s3-segment-store.js';
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export * from './push.js';
|
|
|
36
36
|
export * from './readiness.js';
|
|
37
37
|
export { DEFAULT_REACTION_INITIAL_BACKOFF_MS, DEFAULT_REACTION_LEASE_MS, DEFAULT_REACTION_MAX_ATTEMPTS, DEFAULT_REACTION_MAX_BACKOFF_MS, DEFAULT_REACTION_RETENTION, MAX_REACTION_FAILURE_DETAILS_BYTES, MAX_REACTION_PAYLOAD_BYTES, MAX_REACTIONS_PER_COMMIT, PermanentReactionError, pruneReactions, ReactionRunner, reactionIdempotencyKey, retryDeadLetterReaction, RetryableReactionError, } from './reactions.js';
|
|
38
38
|
export * from './realtime.js';
|
|
39
|
+
export * from './restore.js';
|
|
39
40
|
export * from './relational-rows.js';
|
|
40
41
|
export * from './s3-blob-store.js';
|
|
41
42
|
export * from './s3-segment-store.js';
|
package/dist/operations.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AuthoritativeRelationPlan } from './authoritative-query.js';
|
|
1
2
|
import { type RemoteOperationResponse, type RowValue } from '@syncular/core';
|
|
2
3
|
import type { SyncRequestContext } from './context.js';
|
|
3
4
|
import type { AuthoritativeQueryValue } from './storage.js';
|
|
@@ -20,6 +21,7 @@ export interface AuthoritativeQueryDescriptor<Params = undefined> {
|
|
|
20
21
|
readonly hasParams: boolean;
|
|
21
22
|
readonly sql: string;
|
|
22
23
|
readonly tables: readonly string[];
|
|
24
|
+
readonly relationPlans: readonly AuthoritativeRelationPlan[];
|
|
23
25
|
readonly resultColumns: readonly {
|
|
24
26
|
readonly name: string;
|
|
25
27
|
readonly type: 'string' | 'integer' | 'float' | 'boolean' | 'json' | 'bytes' | 'blob_ref' | 'crdt';
|
package/dist/operations.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { validateAuthoritativeRelationPlan, } from './authoritative-query.js';
|
|
1
2
|
import { decodeRow, decodeRemoteOperationRequest, encodeRow, encodeRemoteOperationResponse, } from '@syncular/core';
|
|
2
|
-
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
|
|
3
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
3
4
|
import { SyncError, syncError } from './errors.js';
|
|
4
5
|
import { processPushOperationsWithTrace } from './push.js';
|
|
5
6
|
import { compileSchema } from './schema.js';
|
|
@@ -97,13 +98,19 @@ function normalizeQueryRows(rows, columns) {
|
|
|
97
98
|
}
|
|
98
99
|
/** Register one generated named query as an authoritative remote operation. */
|
|
99
100
|
export function registerRemoteQuery(descriptor, options) {
|
|
100
|
-
if (descriptor.
|
|
101
|
+
if (!Array.isArray(descriptor.relationPlans) ||
|
|
102
|
+
!descriptor.relationPlans.some((plan) => plan.sql === descriptor.sql) ||
|
|
103
|
+
descriptor.relationPlans.some((plan) => !Array.isArray(plan.relations)) ||
|
|
104
|
+
descriptor.id.length === 0 ||
|
|
101
105
|
new Set(descriptor.tables).size !== descriptor.tables.length ||
|
|
102
106
|
!Array.isArray(descriptor.resultColumns) ||
|
|
103
107
|
descriptor.resultColumns.length === 0 ||
|
|
104
108
|
new Set(descriptor.resultColumns.map((column) => column.name)).size !==
|
|
105
109
|
descriptor.resultColumns.length) {
|
|
106
|
-
throw new Error('remote query requires a non-empty id and unique tables and result columns');
|
|
110
|
+
throw new Error('remote query requires generated relation plans, a non-empty id, and unique tables and result columns; regenerate queries');
|
|
111
|
+
}
|
|
112
|
+
for (const plan of descriptor.relationPlans) {
|
|
113
|
+
validateAuthoritativeRelationPlan(plan, descriptor.tables);
|
|
107
114
|
}
|
|
108
115
|
if (!Number.isSafeInteger(options.maxRows) ||
|
|
109
116
|
options.maxRows < 1 ||
|
|
@@ -173,12 +180,24 @@ export function registerRemoteQuery(descriptor, options) {
|
|
|
173
180
|
if (ctx.storage.queryAuthoritative === undefined) {
|
|
174
181
|
throw syncError('operation.storage_unsupported', 'configured storage does not implement authoritative queries');
|
|
175
182
|
}
|
|
176
|
-
await ctx.storage.ensureSchema(schema);
|
|
177
183
|
const selectedSql = descriptor.sqlFor?.(params) ?? descriptor.sql;
|
|
184
|
+
const plan = descriptor.relationPlans.find((candidate) => candidate.sql === selectedSql);
|
|
185
|
+
if (plan === undefined) {
|
|
186
|
+
throw syncError('operation.invalid_request', 'selected SQL has no generated relation plan; regenerate queries');
|
|
187
|
+
}
|
|
188
|
+
await ctx.storage.ensureSchema(schema);
|
|
189
|
+
const prefix = 'SELECT * FROM (';
|
|
178
190
|
let result;
|
|
179
191
|
try {
|
|
180
192
|
result = await ctx.storage.queryAuthoritative(ctx.partition, {
|
|
181
|
-
|
|
193
|
+
plan: {
|
|
194
|
+
sql: `${prefix}${selectedSql}) AS "_syncular_registered_query" LIMIT ?`,
|
|
195
|
+
relations: plan.relations.map((relation) => ({
|
|
196
|
+
...relation,
|
|
197
|
+
start: relation.start + prefix.length,
|
|
198
|
+
end: relation.end + prefix.length,
|
|
199
|
+
})),
|
|
200
|
+
},
|
|
182
201
|
params: [...descriptor.bind(params), options.maxRows + 1],
|
|
183
202
|
tables: descriptor.tables,
|
|
184
203
|
});
|
|
@@ -362,6 +381,7 @@ export async function handleRemoteOperation(bytes, ctx, registry) {
|
|
|
362
381
|
return encodeRemoteOperationError(error, 'operation.invalid_request');
|
|
363
382
|
}
|
|
364
383
|
try {
|
|
384
|
+
await touchAuthenticatedPartition(ctx);
|
|
365
385
|
if (request.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
366
386
|
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
367
387
|
}
|