@syncular/server 0.15.47 → 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 +7 -1
- 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 +3 -1
- 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.d.ts +1 -0
- package/dist/index.js +1 -0
- 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 +11 -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-dialect.d.ts +1 -1
- package/dist/sqlite-dialect.js +7 -0
- package/dist/sqlite-segment-store.js +14 -5
- package/dist/sqlite-storage.d.ts +4 -1
- package/dist/sqlite-storage.js +81 -11
- package/dist/storage.d.ts +18 -5
- package/package.json +2 -2
- package/src/admin.ts +3 -9
- package/src/blob-handlers.ts +8 -1
- package/src/context.ts +16 -1
- 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.ts +1 -0
- package/src/operations.ts +6 -1
- package/src/postgres-storage.ts +102 -13
- package/src/pull.ts +11 -0
- 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-dialect.ts +7 -0
- package/src/sqlite-segment-store.ts +18 -4
- package/src/sqlite-storage.ts +118 -12
- package/src/storage.ts +28 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type PgExecutor } from './pg-executor.js';
|
|
2
2
|
import type { CompiledSchema, CompiledTable } from './schema.js';
|
|
3
|
-
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
3
|
+
import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorInfo, ClientRecord, CommitMetadata, CommitMetadataQuery, CommitWindowQuery, IndexRowScanQuery, PartitionRegistryEntry, PrunedReactionCounts, ReactionClaimQuery, ReactionFailureUpdate, ReactionListQuery, ReactionPruneQuery, RowScanQuery, ScopeActivityQuery, ScopeCommitActivity, ServerStorage, StorageTransaction, StoredCommit, StoredPushResult, StoredReaction, StoredRow } from './storage.js';
|
|
4
4
|
/**
|
|
5
5
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
6
6
|
*
|
|
@@ -23,7 +23,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
|
|
|
23
23
|
* set, §5.9.5) as an index range, never a scan. `postgres-explain
|
|
24
24
|
* .test.ts` asserts an `Index` node here too.
|
|
25
25
|
*/
|
|
26
|
-
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
|
|
26
|
+
export declare const POSTGRES_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq BIGINT NOT NULL DEFAULT 0,\n horizon_seq BIGINT NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required BOOLEAN NOT NULL DEFAULT FALSE,\n last_authenticated_at_ms BIGINT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq BIGINT NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op SMALLINT NOT NULL,\n row_version BIGINT, scopes JSONB NOT NULL, payload BYTEA,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq BIGINT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result JSONB NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload JSONB NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,\n available_at_ms BIGINT NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms BIGINT, completed_at_ms BIGINT,\n last_failure JSONB,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,\n updated_at_ms BIGINT NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nALTER TABLE sync_clients\n ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
|
|
27
27
|
export declare class PostgresServerStorage implements ServerStorage {
|
|
28
28
|
#private;
|
|
29
29
|
constructor(exec: PgExecutor);
|
|
@@ -32,6 +32,9 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
32
32
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
33
33
|
table(name: string): CompiledTable;
|
|
34
34
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
35
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
36
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
37
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
35
38
|
/**
|
|
36
39
|
* Open a real Postgres transaction. The push handler drives the returned
|
|
37
40
|
* `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
|
package/dist/postgres-storage.js
CHANGED
|
@@ -33,6 +33,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
|
|
|
33
33
|
max_commit_seq BIGINT NOT NULL DEFAULT 0,
|
|
34
34
|
horizon_seq BIGINT NOT NULL DEFAULT 0
|
|
35
35
|
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS sync_partition_registry(
|
|
37
|
+
partition TEXT PRIMARY KEY,
|
|
38
|
+
log_epoch TEXT NOT NULL,
|
|
39
|
+
epoch_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
40
|
+
last_authenticated_at_ms BIGINT NOT NULL
|
|
41
|
+
);
|
|
36
42
|
CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
37
43
|
partition TEXT NOT NULL, tbl TEXT NOT NULL,
|
|
38
44
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
@@ -86,10 +92,13 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
|
86
92
|
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
87
93
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
88
94
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
95
|
+
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
89
96
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
90
97
|
updated_at_ms BIGINT NOT NULL,
|
|
91
98
|
PRIMARY KEY(partition, client_id)
|
|
92
99
|
);
|
|
100
|
+
ALTER TABLE sync_clients
|
|
101
|
+
ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
|
|
93
102
|
CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
94
103
|
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
95
104
|
blob_id TEXT NOT NULL,
|
|
@@ -628,6 +637,58 @@ export class PostgresServerStorage {
|
|
|
628
637
|
this.#tables = schema.tables;
|
|
629
638
|
this.#schemaVersion = schema.version;
|
|
630
639
|
}
|
|
640
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
641
|
+
if (initialLogEpoch.length === 0) {
|
|
642
|
+
throw new Error('initial log epoch must be non-empty');
|
|
643
|
+
}
|
|
644
|
+
const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
|
|
645
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
646
|
+
) VALUES ($1,$2,$3)
|
|
647
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
648
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
|
|
649
|
+
RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
|
|
650
|
+
const row = rows[0];
|
|
651
|
+
if (row === undefined)
|
|
652
|
+
throw new Error('partition registry write did not persist');
|
|
653
|
+
return {
|
|
654
|
+
partition,
|
|
655
|
+
logEpoch: row.log_epoch,
|
|
656
|
+
epochRequired: row.epoch_required,
|
|
657
|
+
lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
661
|
+
if (logEpoch.length === 0)
|
|
662
|
+
throw new Error('log epoch must be non-empty');
|
|
663
|
+
await this.#exec.transaction(async (client) => {
|
|
664
|
+
await client.query(`INSERT INTO sync_partition_registry(
|
|
665
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
666
|
+
) VALUES ($1,$2,TRUE,$3)
|
|
667
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
668
|
+
log_epoch=EXCLUDED.log_epoch,
|
|
669
|
+
epoch_required=TRUE,
|
|
670
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms`, [partition, logEpoch, authenticatedAtMs]);
|
|
671
|
+
await client.query('DELETE FROM sync_clients WHERE partition=$1', [
|
|
672
|
+
partition,
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
return {
|
|
676
|
+
partition,
|
|
677
|
+
logEpoch,
|
|
678
|
+
epochRequired: true,
|
|
679
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async listPartitionRegistry() {
|
|
683
|
+
const { rows } = await this.#exec.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
684
|
+
FROM sync_partition_registry ORDER BY partition`, []);
|
|
685
|
+
return rows.map((row) => ({
|
|
686
|
+
partition: row.partition,
|
|
687
|
+
logEpoch: row.log_epoch,
|
|
688
|
+
epochRequired: row.epoch_required,
|
|
689
|
+
lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
631
692
|
/**
|
|
632
693
|
* Open a real Postgres transaction. The push handler drives the returned
|
|
633
694
|
* `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
|
|
@@ -978,28 +1039,31 @@ export class PostgresServerStorage {
|
|
|
978
1039
|
return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
|
|
979
1040
|
}
|
|
980
1041
|
async getClientRecord(partition, clientId) {
|
|
981
|
-
const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
|
|
1042
|
+
const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 AND client_id=$2', [partition, clientId]);
|
|
982
1043
|
const record = rows[0];
|
|
983
1044
|
if (record === undefined)
|
|
984
1045
|
return undefined;
|
|
985
1046
|
return {
|
|
986
1047
|
clientId: record.client_id,
|
|
987
1048
|
actorId: record.actor_id,
|
|
1049
|
+
wireVersion: asNumber(record.wire_version),
|
|
988
1050
|
cursor: asNumber(record.cursor),
|
|
989
1051
|
updatedAtMs: asNumber(record.updated_at_ms),
|
|
990
1052
|
subscriptions: asJson(record.subscriptions),
|
|
991
1053
|
};
|
|
992
1054
|
}
|
|
993
1055
|
async putClientRecord(partition, record) {
|
|
994
|
-
await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, cursor, subscriptions, updated_at_ms)
|
|
995
|
-
VALUES ($1,$2,$3,$4,$5,$6)
|
|
1056
|
+
await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
|
|
1057
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
996
1058
|
ON CONFLICT (partition, client_id) DO UPDATE
|
|
997
|
-
SET actor_id=EXCLUDED.actor_id,
|
|
1059
|
+
SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
|
|
1060
|
+
cursor=EXCLUDED.cursor,
|
|
998
1061
|
subscriptions=EXCLUDED.subscriptions,
|
|
999
1062
|
updated_at_ms=EXCLUDED.updated_at_ms`, [
|
|
1000
1063
|
partition,
|
|
1001
1064
|
record.clientId,
|
|
1002
1065
|
record.actorId,
|
|
1066
|
+
record.wireVersion,
|
|
1003
1067
|
record.cursor,
|
|
1004
1068
|
JSON.stringify(record.subscriptions),
|
|
1005
1069
|
record.updatedAtMs,
|
|
@@ -1040,10 +1104,11 @@ export class PostgresServerStorage {
|
|
|
1040
1104
|
}
|
|
1041
1105
|
// -- admin/console read surface --------------------------------------------
|
|
1042
1106
|
async listClientRecords(partition) {
|
|
1043
|
-
const { rows } = await this.#exec.query('SELECT client_id, actor_id, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
|
|
1107
|
+
const { rows } = await this.#exec.query('SELECT client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms FROM sync_clients WHERE partition=$1 ORDER BY updated_at_ms DESC', [partition]);
|
|
1044
1108
|
return rows.map((r) => ({
|
|
1045
1109
|
clientId: r.client_id,
|
|
1046
1110
|
actorId: r.actor_id,
|
|
1111
|
+
wireVersion: asNumber(r.wire_version),
|
|
1047
1112
|
cursor: asNumber(r.cursor),
|
|
1048
1113
|
updatedAtMs: asNumber(r.updated_at_ms),
|
|
1049
1114
|
subscriptions: (typeof r.subscriptions === 'string'
|
|
@@ -1114,10 +1179,6 @@ export class PostgresServerStorage {
|
|
|
1114
1179
|
};
|
|
1115
1180
|
}
|
|
1116
1181
|
async listPartitions() {
|
|
1117
|
-
|
|
1118
|
-
// first pull — a partition with only one of the two still shows up.
|
|
1119
|
-
const { rows } = await this.#exec.query(`SELECT partition FROM sync_partitions
|
|
1120
|
-
UNION SELECT partition FROM sync_clients ORDER BY partition`, []);
|
|
1121
|
-
return rows.map((r) => r.partition);
|
|
1182
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1122
1183
|
}
|
|
1123
1184
|
}
|
package/dist/pull.d.ts
CHANGED
|
@@ -41,4 +41,4 @@ export interface PullSectionTrace {
|
|
|
41
41
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
42
42
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
43
43
|
*/
|
|
44
|
-
export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
|
44
|
+
export declare function subscriptionSection(ctx: SyncRequestContext, schema: CompiledSchema, limits: PullLimits, plan: SubscriptionPlan, maxSeq: number, horizonSeq: number, trace?: PullSectionTrace, logEpoch?: string): AsyncGenerator<ResponseFrame, SubscriptionResult>;
|
package/dist/pull.js
CHANGED
|
@@ -134,11 +134,12 @@ function segmentRefFrame(record, extra) {
|
|
|
134
134
|
* bootstrap-storm rule. Returns false when the table is not eligible
|
|
135
135
|
* (the rows lane takes over).
|
|
136
136
|
*/
|
|
137
|
-
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace) {
|
|
137
|
+
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
|
|
138
138
|
const { storage, segments, partition } = ctx;
|
|
139
139
|
const now = clockOf(ctx)();
|
|
140
140
|
const existing = await segments.find({
|
|
141
141
|
partition,
|
|
142
|
+
logEpoch,
|
|
142
143
|
table: plan.table.name,
|
|
143
144
|
schemaVersion: schema.version,
|
|
144
145
|
mediaType: 'sqlite',
|
|
@@ -204,6 +205,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
204
205
|
});
|
|
205
206
|
const record = await segments.put({
|
|
206
207
|
partition,
|
|
208
|
+
logEpoch,
|
|
207
209
|
table: plan.table.name,
|
|
208
210
|
schemaVersion: schema.version,
|
|
209
211
|
mediaType: 'sqlite',
|
|
@@ -223,7 +225,7 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
223
225
|
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, now));
|
|
224
226
|
return true;
|
|
225
227
|
}
|
|
226
|
-
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace) {
|
|
228
|
+
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
|
227
229
|
const { storage, segments, partition } = ctx;
|
|
228
230
|
const serverLimits = limitsOf(ctx);
|
|
229
231
|
const digest = await scopeDigest(plan.effective);
|
|
@@ -237,7 +239,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
237
239
|
if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
238
240
|
startRowCursor === null &&
|
|
239
241
|
plan.table.encryptedColumnIndices.length === 0) {
|
|
240
|
-
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace);
|
|
242
|
+
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
|
|
241
243
|
if (imaged)
|
|
242
244
|
return { complete: true, rowCursor: null };
|
|
243
245
|
}
|
|
@@ -281,6 +283,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
281
283
|
else {
|
|
282
284
|
const record = await segments.put({
|
|
283
285
|
partition,
|
|
286
|
+
logEpoch,
|
|
284
287
|
table: plan.table.name,
|
|
285
288
|
schemaVersion: schema.version,
|
|
286
289
|
mediaType: 'rows',
|
|
@@ -309,8 +312,11 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
309
312
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
310
313
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
311
314
|
*/
|
|
312
|
-
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace) {
|
|
315
|
+
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
|
|
313
316
|
const sub = plan.frame;
|
|
317
|
+
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
318
|
+
throw new Error('subscriptionSection requires a non-empty log epoch');
|
|
319
|
+
}
|
|
314
320
|
if (plan.status === 'revoked') {
|
|
315
321
|
yield {
|
|
316
322
|
type: 'SUB_START',
|
|
@@ -355,7 +361,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
355
361
|
effectiveScopes: plan.effective,
|
|
356
362
|
bootstrap: true,
|
|
357
363
|
};
|
|
358
|
-
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace);
|
|
364
|
+
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
|
|
359
365
|
if (outcome.complete) {
|
|
360
366
|
yield { type: 'SUB_END', nextCursor: asOf };
|
|
361
367
|
}
|
package/dist/realtime.d.ts
CHANGED
|
@@ -96,6 +96,9 @@ export declare class RealtimeSession {
|
|
|
96
96
|
readonly partition: string;
|
|
97
97
|
readonly actorId: string;
|
|
98
98
|
readonly clientId: string;
|
|
99
|
+
readonly logEpoch: string;
|
|
100
|
+
/** Response/delta layout selected by the most recent socket round. */
|
|
101
|
+
wireVersion: number;
|
|
99
102
|
/** Highest contiguously applied commitSeq acknowledged by the client. */
|
|
100
103
|
cursor: number;
|
|
101
104
|
/** Suppress deltas until the client catches up via pull + ack (§8.2). */
|
|
@@ -105,7 +108,7 @@ export declare class RealtimeSession {
|
|
|
105
108
|
registrations: readonly Registration[];
|
|
106
109
|
/** Epoch-ms (hub clock) at registration, for `realtime.closed`. */
|
|
107
110
|
readonly openedAtMs: number;
|
|
108
|
-
constructor(hub: RealtimeHub, options: RealtimeConnectOptions, registrations: readonly Registration[], cursor: number, latestSeq: number, clock: () => number, maxDeltaBytes: number, storage: ServerStorage, events: SyncularServerEvents | undefined);
|
|
111
|
+
constructor(hub: RealtimeHub, options: RealtimeConnectOptions, registrations: readonly Registration[], cursor: number, latestSeq: number, clock: () => number, maxDeltaBytes: number, storage: ServerStorage, logEpoch: string, wireVersion: number, events: SyncularServerEvents | undefined);
|
|
109
112
|
/** Feed an inbound text frame (client → server control message, §8.2
|
|
110
113
|
* ack, §8.6.2 presence). */
|
|
111
114
|
handleMessage(text: string): void;
|
package/dist/realtime.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* the `sync` wake-up (§8.3).
|
|
15
15
|
*/
|
|
16
16
|
import { DecodeError, decodeMessage, encodeMessage, encodePresenceError, encodePresenceFanout, MessageStreamScanner, PROTOCOL_WIRE_VERSION, parseRealtimePresencePublish, REALTIME_TAG_DELTA, REALTIME_TAG_ROUND, } from '@syncular/core';
|
|
17
|
-
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE } from './context.js';
|
|
17
|
+
import { REMOTE_COMMAND_CLIENT_ID_PREFIX, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
18
18
|
import { SyncError, syncError } from './errors.js';
|
|
19
19
|
import { emitEvent } from './events.js';
|
|
20
20
|
import { createSyncResponseStream } from './handler.js';
|
|
@@ -120,6 +120,9 @@ export class RealtimeSession {
|
|
|
120
120
|
partition;
|
|
121
121
|
actorId;
|
|
122
122
|
clientId;
|
|
123
|
+
logEpoch;
|
|
124
|
+
/** Response/delta layout selected by the most recent socket round. */
|
|
125
|
+
wireVersion;
|
|
123
126
|
/** Highest contiguously applied commitSeq acknowledged by the client. */
|
|
124
127
|
cursor;
|
|
125
128
|
/** Suppress deltas until the client catches up via pull + ack (§8.2). */
|
|
@@ -144,11 +147,13 @@ export class RealtimeSession {
|
|
|
144
147
|
/** §8.6.4 rate cap: per-scope-key last-fanout time + a pending latest
|
|
145
148
|
* document coalesced while over the cap. */
|
|
146
149
|
#presenceRate = new Map();
|
|
147
|
-
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, events) {
|
|
150
|
+
constructor(hub, options, registrations, cursor, latestSeq, clock, maxDeltaBytes, storage, logEpoch, wireVersion, events) {
|
|
148
151
|
this.sessionId = crypto.randomUUID();
|
|
149
152
|
this.partition = options.partition;
|
|
150
153
|
this.actorId = options.actorId;
|
|
151
154
|
this.clientId = options.clientId;
|
|
155
|
+
this.logEpoch = logEpoch;
|
|
156
|
+
this.wireVersion = wireVersion;
|
|
152
157
|
this.cursor = cursor;
|
|
153
158
|
this.lastKnownSeq = latestSeq;
|
|
154
159
|
this.wakePending = cursor < latestSeq;
|
|
@@ -376,6 +381,10 @@ export class RealtimeSession {
|
|
|
376
381
|
this.#activeRound = undefined;
|
|
377
382
|
};
|
|
378
383
|
try {
|
|
384
|
+
const requestedWireVersion = (requestBytes[4] ?? 0) | ((requestBytes[5] ?? 0) << 8);
|
|
385
|
+
if (requestedWireVersion === 1 || requestedWireVersion === 2) {
|
|
386
|
+
this.wireVersion = requestedWireVersion;
|
|
387
|
+
}
|
|
379
388
|
let stream;
|
|
380
389
|
try {
|
|
381
390
|
// §8.7: the round's clientId must match the connection's —
|
|
@@ -398,7 +407,7 @@ export class RealtimeSession {
|
|
|
398
407
|
? error
|
|
399
408
|
: syncError(error.code, error.message);
|
|
400
409
|
finishRound(); // END is in this one chunk
|
|
401
|
-
await this.#sendRoundChunk(errorResponseBytes(sync));
|
|
410
|
+
await this.#sendRoundChunk(errorResponseBytes(sync, this.wireVersion, this.logEpoch));
|
|
402
411
|
return;
|
|
403
412
|
}
|
|
404
413
|
throw error;
|
|
@@ -550,7 +559,14 @@ export class RealtimeSession {
|
|
|
550
559
|
this.sendWake('catchup-required');
|
|
551
560
|
return;
|
|
552
561
|
}
|
|
553
|
-
const frames = [
|
|
562
|
+
const frames = [
|
|
563
|
+
{
|
|
564
|
+
type: 'RESP_HEADER',
|
|
565
|
+
...(this.wireVersion >= 2
|
|
566
|
+
? { logEpoch: this.logEpoch, resetRequired: false }
|
|
567
|
+
: {}),
|
|
568
|
+
},
|
|
569
|
+
];
|
|
554
570
|
for (const section of sections) {
|
|
555
571
|
frames.push({
|
|
556
572
|
type: 'SUB_START',
|
|
@@ -571,7 +587,7 @@ export class RealtimeSession {
|
|
|
571
587
|
frames.push({ type: 'SUB_END', nextCursor: commit.commitSeq });
|
|
572
588
|
}
|
|
573
589
|
const bytes = encodeMessage({
|
|
574
|
-
wireVersion:
|
|
590
|
+
wireVersion: this.wireVersion,
|
|
575
591
|
msgKind: 'response',
|
|
576
592
|
frames,
|
|
577
593
|
});
|
|
@@ -770,6 +786,11 @@ export class RealtimeHub {
|
|
|
770
786
|
async connect(options) {
|
|
771
787
|
const { storage } = this.#config;
|
|
772
788
|
const clock = this.#config.clock ?? Date.now;
|
|
789
|
+
const registry = await touchAuthenticatedPartition({
|
|
790
|
+
...this.#config,
|
|
791
|
+
partition: options.partition,
|
|
792
|
+
actorId: options.actorId,
|
|
793
|
+
});
|
|
773
794
|
if (options.clientId.startsWith(REMOTE_COMMAND_CLIENT_ID_PREFIX)) {
|
|
774
795
|
throw syncError('sync.invalid_client_id', 'clientId uses a reserved server-command namespace (§1.5)');
|
|
775
796
|
}
|
|
@@ -780,7 +801,7 @@ export class RealtimeHub {
|
|
|
780
801
|
const registrations = await this.loadRegistrations(options.partition, options.actorId, options.clientId);
|
|
781
802
|
const latestSeq = await storage.getMaxCommitSeq(options.partition);
|
|
782
803
|
const cursor = record?.cursor ?? -1;
|
|
783
|
-
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, this.#config.events);
|
|
804
|
+
const session = new RealtimeSession(this, options, registrations, cursor, latestSeq, clock, this.#config.maxDeltaBytes ?? DEFAULT_MAX_DELTA_BYTES, storage, registry.logEpoch, record?.wireVersion ?? PROTOCOL_WIRE_VERSION, this.#config.events);
|
|
784
805
|
this.#sessions.add(session);
|
|
785
806
|
const helloResult = options.send(JSON.stringify({
|
|
786
807
|
event: 'hello',
|
|
@@ -860,12 +881,15 @@ export function createRealtimeHub(config) {
|
|
|
860
881
|
/** §8.7 failures: the socket has no HTTP status surface, so a
|
|
861
882
|
* request-level failure becomes a minimal RESP_HEADER/ERROR/END
|
|
862
883
|
* response message delivered as the round's response stream. */
|
|
863
|
-
function errorResponseBytes(error) {
|
|
884
|
+
function errorResponseBytes(error, wireVersion, logEpoch) {
|
|
864
885
|
return encodeMessage({
|
|
865
|
-
wireVersion
|
|
886
|
+
wireVersion,
|
|
866
887
|
msgKind: 'response',
|
|
867
888
|
frames: [
|
|
868
|
-
{
|
|
889
|
+
{
|
|
890
|
+
type: 'RESP_HEADER',
|
|
891
|
+
...(wireVersion >= 2 ? { logEpoch, resetRequired: false } : {}),
|
|
892
|
+
},
|
|
869
893
|
{
|
|
870
894
|
type: 'ERROR',
|
|
871
895
|
code: error.code,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PartitionRegistryEntry, ServerStorage } from './storage.js';
|
|
2
|
+
export interface RotatePartitionLogEpochOptions {
|
|
3
|
+
readonly storage: ServerStorage;
|
|
4
|
+
readonly partition: string;
|
|
5
|
+
readonly nowMs?: number;
|
|
6
|
+
/** Deterministic operator/test override. Defaults to a random UUID. */
|
|
7
|
+
readonly logEpoch?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Fence clients from a prior authoritative database timeline after restore.
|
|
11
|
+
* Call while traffic and realtime delivery remain stopped (§2.1).
|
|
12
|
+
*/
|
|
13
|
+
export declare function rotatePartitionLogEpoch(options: RotatePartitionLogEpochOptions): Promise<PartitionRegistryEntry>;
|
package/dist/restore.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fence clients from a prior authoritative database timeline after restore.
|
|
3
|
+
* Call while traffic and realtime delivery remain stopped (§2.1).
|
|
4
|
+
*/
|
|
5
|
+
export function rotatePartitionLogEpoch(options) {
|
|
6
|
+
if (options.partition.length === 0) {
|
|
7
|
+
throw new TypeError('partition must be non-empty');
|
|
8
|
+
}
|
|
9
|
+
const logEpoch = options.logEpoch ?? crypto.randomUUID();
|
|
10
|
+
if (logEpoch.length === 0)
|
|
11
|
+
throw new TypeError('logEpoch must be non-empty');
|
|
12
|
+
return options.storage.rotatePartitionLogEpoch(options.partition, logEpoch, options.nowMs ?? Date.now());
|
|
13
|
+
}
|
package/dist/s3-segment-store.js
CHANGED
|
@@ -70,7 +70,13 @@ function parseRecordJson(json, source) {
|
|
|
70
70
|
throw new Error(`S3SegmentStore: corrupt record in ${source}`);
|
|
71
71
|
}
|
|
72
72
|
const r = parsed;
|
|
73
|
-
const strings = [
|
|
73
|
+
const strings = [
|
|
74
|
+
'segmentId',
|
|
75
|
+
'partition',
|
|
76
|
+
'logEpoch',
|
|
77
|
+
'table',
|
|
78
|
+
'scopeDigest',
|
|
79
|
+
];
|
|
74
80
|
const numbers = [
|
|
75
81
|
'schemaVersion',
|
|
76
82
|
'asOfCommitSeq',
|
|
@@ -99,6 +105,7 @@ function parseRecordJson(json, source) {
|
|
|
99
105
|
return {
|
|
100
106
|
segmentId: r.segmentId,
|
|
101
107
|
partition: r.partition,
|
|
108
|
+
logEpoch: r.logEpoch,
|
|
102
109
|
table: r.table,
|
|
103
110
|
schemaVersion: r.schemaVersion,
|
|
104
111
|
mediaType: r.mediaType,
|
|
@@ -136,6 +143,7 @@ export class S3SegmentStore {
|
|
|
136
143
|
async #findKeyFor(key) {
|
|
137
144
|
const canonical = JSON.stringify([
|
|
138
145
|
key.partition,
|
|
146
|
+
key.logEpoch,
|
|
139
147
|
key.table,
|
|
140
148
|
key.schemaVersion,
|
|
141
149
|
key.mediaType,
|
|
@@ -304,6 +312,7 @@ export class S3SegmentStore {
|
|
|
304
312
|
return undefined;
|
|
305
313
|
const record = parseRecordJson(await response.text(), 'reuse pointer');
|
|
306
314
|
if (record.partition !== key.partition ||
|
|
315
|
+
record.logEpoch !== key.logEpoch ||
|
|
307
316
|
record.table !== key.table ||
|
|
308
317
|
record.schemaVersion !== key.schemaVersion ||
|
|
309
318
|
record.mediaType !== key.mediaType ||
|
package/dist/seed.js
CHANGED
|
@@ -105,8 +105,42 @@ export async function seedMutations(config, target, mutations) {
|
|
|
105
105
|
payload: encodeRow(table.columns, values),
|
|
106
106
|
};
|
|
107
107
|
});
|
|
108
|
+
const requestContext = {
|
|
109
|
+
...config,
|
|
110
|
+
partition: target.partition,
|
|
111
|
+
actorId: target.actorId,
|
|
112
|
+
};
|
|
113
|
+
const acquisition = decodeMessage(await handleSyncRequest(encodeMessage({
|
|
114
|
+
wireVersion: PROTOCOL_WIRE_VERSION,
|
|
115
|
+
msgKind: 'request',
|
|
116
|
+
frames: [
|
|
117
|
+
{
|
|
118
|
+
type: 'REQ_HEADER',
|
|
119
|
+
clientId,
|
|
120
|
+
schemaVersion: config.schema.version,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: 'PULL_HEADER',
|
|
124
|
+
limitCommits: 0,
|
|
125
|
+
limitSnapshotRows: 0,
|
|
126
|
+
maxSnapshotPages: 0,
|
|
127
|
+
accept: 0b0011,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
}), requestContext));
|
|
131
|
+
const acquisitionHeader = acquisition.frames[0];
|
|
132
|
+
if (acquisition.msgKind !== 'response' ||
|
|
133
|
+
acquisitionHeader?.type !== 'RESP_HEADER' ||
|
|
134
|
+
acquisitionHeader.logEpoch === undefined) {
|
|
135
|
+
throw new SyncError('sync.invalid_request', 'seedMutations: epoch acquisition returned an invalid response');
|
|
136
|
+
}
|
|
108
137
|
const frames = [
|
|
109
|
-
{
|
|
138
|
+
{
|
|
139
|
+
type: 'REQ_HEADER',
|
|
140
|
+
clientId,
|
|
141
|
+
schemaVersion: config.schema.version,
|
|
142
|
+
logEpoch: acquisitionHeader.logEpoch,
|
|
143
|
+
},
|
|
110
144
|
{ type: 'PUSH_COMMIT', clientCommitId, operations },
|
|
111
145
|
// A pull that asks for nothing: the round exists for its push half.
|
|
112
146
|
{
|
|
@@ -132,9 +166,7 @@ export async function seedMutations(config, target, mutations) {
|
|
|
132
166
|
msgKind: 'request',
|
|
133
167
|
frames,
|
|
134
168
|
}), {
|
|
135
|
-
...
|
|
136
|
-
partition: target.partition,
|
|
137
|
-
actorId: target.actorId,
|
|
169
|
+
...requestContext,
|
|
138
170
|
events: config.events === undefined
|
|
139
171
|
? capture
|
|
140
172
|
: composeEvents(config.events, capture),
|
package/dist/segment-download.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clockOf, RESOLVER_OUTAGE } from './context.js';
|
|
1
|
+
import { clockOf, RESOLVER_OUTAGE, touchAuthenticatedPartition, } from './context.js';
|
|
2
2
|
import { SyncError, syncError } from './errors.js';
|
|
3
3
|
import { emitEvent } from './events.js';
|
|
4
4
|
import { compileSchema } from './schema.js';
|
|
@@ -60,8 +60,11 @@ export async function handleSegmentDownload(ctx, request) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
async function downloadSegment(ctx, request) {
|
|
63
|
+
const registry = await touchAuthenticatedPartition(ctx);
|
|
63
64
|
const entry = await ctx.segments.get(request.segmentId);
|
|
64
|
-
if (entry === undefined ||
|
|
65
|
+
if (entry === undefined ||
|
|
66
|
+
entry.record.partition !== ctx.partition ||
|
|
67
|
+
entry.record.logEpoch !== registry.logEpoch) {
|
|
65
68
|
throw syncError('sync.not_found', 'unknown segment (§5.5)');
|
|
66
69
|
}
|
|
67
70
|
if (entry.record.expiresAtMs <= clockOf(ctx)()) {
|
package/dist/segment-store.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export declare const DEFAULT_SEGMENT_TTL_MS: number;
|
|
2
2
|
export interface SegmentMetadata {
|
|
3
3
|
readonly partition: string;
|
|
4
|
+
/** Partition log continuity that produced these bytes (§2.1). */
|
|
5
|
+
readonly logEpoch: string;
|
|
4
6
|
readonly table: string;
|
|
5
7
|
readonly schemaVersion: number;
|
|
6
8
|
readonly mediaType: 'rows' | 'sqlite';
|
|
@@ -23,6 +25,7 @@ export interface SegmentRecord extends SegmentMetadata {
|
|
|
23
25
|
*/
|
|
24
26
|
export interface SegmentFindKey {
|
|
25
27
|
readonly partition: string;
|
|
28
|
+
readonly logEpoch: string;
|
|
26
29
|
readonly table: string;
|
|
27
30
|
readonly schemaVersion: number;
|
|
28
31
|
readonly mediaType: 'rows' | 'sqlite';
|
package/dist/segment-store.js
CHANGED
|
@@ -35,6 +35,7 @@ export class MemorySegmentStore {
|
|
|
35
35
|
async find(key, nowMs) {
|
|
36
36
|
for (const { record } of this.#entries.values()) {
|
|
37
37
|
if (record.partition === key.partition &&
|
|
38
|
+
record.logEpoch === key.logEpoch &&
|
|
38
39
|
record.table === key.table &&
|
|
39
40
|
record.schemaVersion === key.schemaVersion &&
|
|
40
41
|
record.mediaType === key.mediaType &&
|
|
@@ -2,7 +2,8 @@ import { Database } from 'bun:sqlite';
|
|
|
2
2
|
import type { SqliteDatabase, SqliteRunResult, SqliteStatement, SqliteValue } from './sqlite-driver.js';
|
|
3
3
|
export declare class BunSqliteDatabase implements SqliteDatabase {
|
|
4
4
|
readonly native: Database;
|
|
5
|
-
constructor(
|
|
5
|
+
constructor(value?: string | Database);
|
|
6
|
+
static deserialize(bytes: Uint8Array): BunSqliteDatabase;
|
|
6
7
|
exec(sql: string): void;
|
|
7
8
|
run(sql: string, bindings?: readonly SqliteValue[]): SqliteRunResult;
|
|
8
9
|
query<Row, Params extends readonly SqliteValue[]>(sql: string): SqliteStatement<Row, Params>;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
2
|
export class BunSqliteDatabase {
|
|
3
3
|
native;
|
|
4
|
-
constructor(
|
|
5
|
-
this.native = new Database(
|
|
4
|
+
constructor(value = ':memory:') {
|
|
5
|
+
this.native = typeof value === 'string' ? new Database(value) : value;
|
|
6
|
+
}
|
|
7
|
+
static deserialize(bytes) {
|
|
8
|
+
return new BunSqliteDatabase(Database.deserialize(bytes));
|
|
6
9
|
}
|
|
7
10
|
exec(sql) {
|
|
8
11
|
this.native.exec(sql);
|
package/dist/sqlite-dialect.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { StoredChange, StoredCommit, StoredPushResult, StoredRow } from './
|
|
|
30
30
|
* the Postgres storage documents (§3.1, performance-by-
|
|
31
31
|
* construction).
|
|
32
32
|
*/
|
|
33
|
-
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
|
|
33
|
+
export declare const SQLITE_DDL = "\nCREATE TABLE IF NOT EXISTS sync_partitions(\n partition TEXT PRIMARY KEY,\n max_commit_seq INTEGER NOT NULL DEFAULT 0,\n horizon_seq INTEGER NOT NULL DEFAULT 0\n);\nCREATE TABLE IF NOT EXISTS sync_partition_registry(\n partition TEXT PRIMARY KEY,\n log_epoch TEXT NOT NULL,\n epoch_required INTEGER NOT NULL DEFAULT 0,\n last_authenticated_at_ms INTEGER NOT NULL\n);\nCREATE TABLE IF NOT EXISTS sync_row_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, row_id)\n);\nCREATE TABLE IF NOT EXISTS sync_commits(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n client_id TEXT NOT NULL, client_commit_id TEXT NOT NULL,\n actor_id TEXT NOT NULL, created_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, commit_seq)\n);\nCREATE INDEX IF NOT EXISTS sync_commits_by_time\n ON sync_commits(partition, created_at_ms);\nCREATE TABLE IF NOT EXISTS sync_changes(\n partition TEXT NOT NULL, commit_seq INTEGER NOT NULL, idx INTEGER NOT NULL,\n tbl TEXT NOT NULL, row_id TEXT NOT NULL, op INTEGER NOT NULL,\n row_version INTEGER, scopes TEXT NOT NULL, payload BLOB,\n PRIMARY KEY(partition, commit_seq, idx)\n);\nCREATE INDEX IF NOT EXISTS sync_changes_by_table\n ON sync_changes(partition, commit_seq, tbl, idx);\nCREATE TABLE IF NOT EXISTS sync_change_scopes(\n partition TEXT NOT NULL, tbl TEXT NOT NULL,\n var TEXT NOT NULL, value TEXT NOT NULL, commit_seq INTEGER NOT NULL,\n PRIMARY KEY(partition, tbl, var, value, commit_seq)\n);\nCREATE TABLE IF NOT EXISTS sync_push_results(\n partition TEXT NOT NULL, client_id TEXT NOT NULL,\n client_commit_id TEXT NOT NULL, result TEXT NOT NULL,\n PRIMARY KEY(partition, client_id, client_commit_id)\n);\nCREATE TABLE IF NOT EXISTS sync_reactions(\n partition TEXT NOT NULL, idempotency_key TEXT NOT NULL,\n type TEXT NOT NULL, version INTEGER NOT NULL, payload TEXT NOT NULL,\n source_client_id TEXT NOT NULL, source_client_commit_id TEXT NOT NULL,\n source_commit_seq INTEGER NOT NULL, created_at_ms INTEGER NOT NULL,\n available_at_ms INTEGER NOT NULL, status TEXT NOT NULL,\n attempts INTEGER NOT NULL, max_attempts INTEGER NOT NULL,\n lease_owner TEXT, lease_expires_at_ms INTEGER, completed_at_ms INTEGER,\n last_failure TEXT,\n PRIMARY KEY(partition, idempotency_key),\n CHECK(status IN ('pending', 'leased', 'completed', 'dead-letter'))\n);\nCREATE INDEX IF NOT EXISTS sync_reactions_due\n ON sync_reactions(partition, status, available_at_ms, created_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_lease\n ON sync_reactions(partition, status, lease_expires_at_ms);\nCREATE INDEX IF NOT EXISTS sync_reactions_completed\n ON sync_reactions(partition, status, completed_at_ms, idempotency_key);\nCREATE INDEX IF NOT EXISTS sync_reactions_dead_letter\n ON sync_reactions(partition, status, available_at_ms, idempotency_key);\nCREATE TABLE IF NOT EXISTS sync_clients(\n partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,\n wire_version INTEGER NOT NULL DEFAULT 1,\n cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL,\n PRIMARY KEY(partition, client_id)\n);\nCREATE TABLE IF NOT EXISTS sync_blob_refs(\n partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,\n blob_id TEXT NOT NULL,\n PRIMARY KEY(partition, tbl, row_id, blob_id)\n);\nCREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob\n ON sync_blob_refs(partition, blob_id);\n";
|
|
34
34
|
/** Split the DDL into individual statements (D1 applies them one by one). */
|
|
35
35
|
export declare function sqliteDdlStatements(): string[];
|
|
36
36
|
/** `?,?,…` for an `IN (…)` clause of `count` positional parameters. */
|
package/dist/sqlite-dialect.js
CHANGED
|
@@ -18,6 +18,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
|
|
|
18
18
|
max_commit_seq INTEGER NOT NULL DEFAULT 0,
|
|
19
19
|
horizon_seq INTEGER NOT NULL DEFAULT 0
|
|
20
20
|
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sync_partition_registry(
|
|
22
|
+
partition TEXT PRIMARY KEY,
|
|
23
|
+
log_epoch TEXT NOT NULL,
|
|
24
|
+
epoch_required INTEGER NOT NULL DEFAULT 0,
|
|
25
|
+
last_authenticated_at_ms INTEGER NOT NULL
|
|
26
|
+
);
|
|
21
27
|
CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
22
28
|
partition TEXT NOT NULL, tbl TEXT NOT NULL,
|
|
23
29
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
@@ -71,6 +77,7 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
|
71
77
|
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
72
78
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
73
79
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
80
|
+
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
74
81
|
cursor INTEGER NOT NULL, subscriptions TEXT NOT NULL,
|
|
75
82
|
updated_at_ms INTEGER NOT NULL,
|
|
76
83
|
PRIMARY KEY(partition, client_id)
|