@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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { CommitPruneQuery, CommitPruneResult } from './storage.js';
|
|
1
2
|
import { type PgExecutor } from './pg-executor.js';
|
|
2
3
|
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';
|
|
4
|
+
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
5
|
/**
|
|
5
6
|
* Schema DDL. Applied by `PostgresServerStorage.migrate()` (idempotent).
|
|
6
7
|
*
|
|
@@ -23,7 +24,7 @@ import type { AuthoritativeQueryRequest, AuthoritativeQueryResult, ClientCursorI
|
|
|
23
24
|
* set, §5.9.5) as an index range, never a scan. `postgres-explain
|
|
24
25
|
* .test.ts` asserts an `Index` node here too.
|
|
25
26
|
*/
|
|
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";
|
|
27
|
+
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
28
|
export declare class PostgresServerStorage implements ServerStorage {
|
|
28
29
|
#private;
|
|
29
30
|
constructor(exec: PgExecutor);
|
|
@@ -32,6 +33,9 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
32
33
|
/** Resolve a table's compiled schema; row operations require `ensureSchema`. */
|
|
33
34
|
table(name: string): CompiledTable;
|
|
34
35
|
ensureSchema(schema: CompiledSchema): Promise<void>;
|
|
36
|
+
touchPartition(partition: string, authenticatedAtMs: number, initialLogEpoch: string): Promise<PartitionRegistryEntry>;
|
|
37
|
+
rotatePartitionLogEpoch(partition: string, logEpoch: string, authenticatedAtMs: number): Promise<PartitionRegistryEntry>;
|
|
38
|
+
listPartitionRegistry(): Promise<PartitionRegistryEntry[]>;
|
|
35
39
|
/**
|
|
36
40
|
* Open a real Postgres transaction. The push handler drives the returned
|
|
37
41
|
* `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
|
|
@@ -43,9 +47,10 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
43
47
|
begin(partition: string): Promise<StorageTransaction>;
|
|
44
48
|
getMaxCommitSeq(partition: string): Promise<number>;
|
|
45
49
|
queryAuthoritative(partition: string, query: AuthoritativeQueryRequest): Promise<AuthoritativeQueryResult>;
|
|
50
|
+
getPartitionLogEpoch(partition: string): Promise<string | undefined>;
|
|
46
51
|
getHorizonSeq(partition: string): Promise<number>;
|
|
47
52
|
setHorizonSeq(partition: string, seq: number): Promise<void>;
|
|
48
|
-
pruneCommitsThrough(partition: string,
|
|
53
|
+
pruneCommitsThrough(partition: string, query: CommitPruneQuery): Promise<CommitPruneResult>;
|
|
49
54
|
getCommitSeqBefore(partition: string, createdBeforeMs: number): Promise<number>;
|
|
50
55
|
getRow(partition: string, table: string, rowId: string): Promise<StoredRow | undefined>;
|
|
51
56
|
getPushResult(partition: string, clientId: string, clientCommitId: string): Promise<StoredPushResult | undefined>;
|
|
@@ -62,6 +67,7 @@ export declare class PostgresServerStorage implements ServerStorage {
|
|
|
62
67
|
scanRowsByIndex(partition: string, query: IndexRowScanQuery): Promise<StoredRow[]>;
|
|
63
68
|
getClientRecord(partition: string, clientId: string): Promise<ClientRecord | undefined>;
|
|
64
69
|
putClientRecord(partition: string, record: ClientRecord): Promise<void>;
|
|
70
|
+
getActiveClientCursorFloor(partition: string, cutoffMs: number): Promise<number | null>;
|
|
65
71
|
listClientCursors(partition: string): Promise<ClientCursorInfo[]>;
|
|
66
72
|
listRowsReferencingBlob(partition: string, blobId: string): Promise<{
|
|
67
73
|
readonly table: string;
|
package/dist/postgres-storage.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateCommitPruneQuery } from './prune.js';
|
|
2
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
1
3
|
import { bindAuthoritativePartition, postgresPlaceholders, prepareAuthoritativeQuery, } from './authoritative-query.js';
|
|
2
4
|
import { syncError } from './errors.js';
|
|
3
5
|
import { asBytes, asNumber, } from './pg-executor.js';
|
|
@@ -33,6 +35,12 @@ CREATE TABLE IF NOT EXISTS sync_partitions(
|
|
|
33
35
|
max_commit_seq BIGINT NOT NULL DEFAULT 0,
|
|
34
36
|
horizon_seq BIGINT NOT NULL DEFAULT 0
|
|
35
37
|
);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sync_partition_registry(
|
|
39
|
+
partition TEXT PRIMARY KEY,
|
|
40
|
+
log_epoch TEXT NOT NULL,
|
|
41
|
+
epoch_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
42
|
+
last_authenticated_at_ms BIGINT NOT NULL
|
|
43
|
+
);
|
|
36
44
|
CREATE TABLE IF NOT EXISTS sync_row_scopes(
|
|
37
45
|
partition TEXT NOT NULL, tbl TEXT NOT NULL,
|
|
38
46
|
var TEXT NOT NULL, value TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
@@ -86,10 +94,13 @@ CREATE INDEX IF NOT EXISTS sync_reactions_dead_letter
|
|
|
86
94
|
ON sync_reactions(partition, status, available_at_ms, idempotency_key);
|
|
87
95
|
CREATE TABLE IF NOT EXISTS sync_clients(
|
|
88
96
|
partition TEXT NOT NULL, client_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
|
97
|
+
wire_version INTEGER NOT NULL DEFAULT 1,
|
|
89
98
|
cursor BIGINT NOT NULL, subscriptions JSONB NOT NULL,
|
|
90
99
|
updated_at_ms BIGINT NOT NULL,
|
|
91
100
|
PRIMARY KEY(partition, client_id)
|
|
92
101
|
);
|
|
102
|
+
ALTER TABLE sync_clients
|
|
103
|
+
ADD COLUMN IF NOT EXISTS wire_version INTEGER NOT NULL DEFAULT 1;
|
|
93
104
|
CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
94
105
|
partition TEXT NOT NULL, tbl TEXT NOT NULL, row_id TEXT NOT NULL,
|
|
95
106
|
blob_id TEXT NOT NULL,
|
|
@@ -98,6 +109,11 @@ CREATE TABLE IF NOT EXISTS sync_blob_refs(
|
|
|
98
109
|
CREATE INDEX IF NOT EXISTS sync_blob_refs_by_blob
|
|
99
110
|
ON sync_blob_refs(partition, blob_id);
|
|
100
111
|
`;
|
|
112
|
+
async function lockPartitionOn(client, partition) {
|
|
113
|
+
await client.query(`INSERT INTO sync_partitions(partition, max_commit_seq) VALUES ($1, 0)
|
|
114
|
+
ON CONFLICT (partition) DO NOTHING`, [partition]);
|
|
115
|
+
await client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [partition]);
|
|
116
|
+
}
|
|
101
117
|
function toBase64(bytes) {
|
|
102
118
|
return Buffer.from(bytes).toString('base64');
|
|
103
119
|
}
|
|
@@ -372,9 +388,7 @@ class PostgresTransaction {
|
|
|
372
388
|
}
|
|
373
389
|
async lockPartitionForPush() {
|
|
374
390
|
this.#assertOpen();
|
|
375
|
-
await this.#client
|
|
376
|
-
ON CONFLICT (partition) DO NOTHING`, [this.#partition]);
|
|
377
|
-
await this.#client.query('SELECT max_commit_seq FROM sync_partitions WHERE partition=$1 FOR UPDATE', [this.#partition]);
|
|
391
|
+
await lockPartitionOn(this.#client, this.#partition);
|
|
378
392
|
await this.#client.query('SAVEPOINT syncular_push_candidate');
|
|
379
393
|
this.#pushApplySavepoint = true;
|
|
380
394
|
}
|
|
@@ -628,6 +642,59 @@ export class PostgresServerStorage {
|
|
|
628
642
|
this.#tables = schema.tables;
|
|
629
643
|
this.#schemaVersion = schema.version;
|
|
630
644
|
}
|
|
645
|
+
async touchPartition(partition, authenticatedAtMs, initialLogEpoch) {
|
|
646
|
+
if (initialLogEpoch.length === 0) {
|
|
647
|
+
throw new Error('initial log epoch must be non-empty');
|
|
648
|
+
}
|
|
649
|
+
const { rows } = await this.#exec.query(`INSERT INTO sync_partition_registry(
|
|
650
|
+
partition, log_epoch, last_authenticated_at_ms
|
|
651
|
+
) VALUES ($1,$2,$3)
|
|
652
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
653
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms
|
|
654
|
+
RETURNING log_epoch, epoch_required, last_authenticated_at_ms`, [partition, initialLogEpoch, authenticatedAtMs]);
|
|
655
|
+
const row = rows[0];
|
|
656
|
+
if (row === undefined)
|
|
657
|
+
throw new Error('partition registry write did not persist');
|
|
658
|
+
return {
|
|
659
|
+
partition,
|
|
660
|
+
logEpoch: row.log_epoch,
|
|
661
|
+
epochRequired: row.epoch_required,
|
|
662
|
+
lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
async rotatePartitionLogEpoch(partition, logEpoch, authenticatedAtMs) {
|
|
666
|
+
if (logEpoch.length === 0)
|
|
667
|
+
throw new Error('log epoch must be non-empty');
|
|
668
|
+
await this.#exec.transaction(async (client) => {
|
|
669
|
+
await lockPartitionOn(client, partition);
|
|
670
|
+
await client.query(`INSERT INTO sync_partition_registry(
|
|
671
|
+
partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
672
|
+
) VALUES ($1,$2,TRUE,$3)
|
|
673
|
+
ON CONFLICT(partition) DO UPDATE SET
|
|
674
|
+
log_epoch=EXCLUDED.log_epoch,
|
|
675
|
+
epoch_required=TRUE,
|
|
676
|
+
last_authenticated_at_ms=EXCLUDED.last_authenticated_at_ms`, [partition, logEpoch, authenticatedAtMs]);
|
|
677
|
+
await client.query('DELETE FROM sync_clients WHERE partition=$1', [
|
|
678
|
+
partition,
|
|
679
|
+
]);
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
partition,
|
|
683
|
+
logEpoch,
|
|
684
|
+
epochRequired: true,
|
|
685
|
+
lastAuthenticatedAtMs: authenticatedAtMs,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
async listPartitionRegistry() {
|
|
689
|
+
const { rows } = await this.#exec.query(`SELECT partition, log_epoch, epoch_required, last_authenticated_at_ms
|
|
690
|
+
FROM sync_partition_registry ORDER BY partition`, []);
|
|
691
|
+
return rows.map((row) => ({
|
|
692
|
+
partition: row.partition,
|
|
693
|
+
logEpoch: row.log_epoch,
|
|
694
|
+
epochRequired: row.epoch_required,
|
|
695
|
+
lastAuthenticatedAtMs: asNumber(row.last_authenticated_at_ms),
|
|
696
|
+
}));
|
|
697
|
+
}
|
|
631
698
|
/**
|
|
632
699
|
* Open a real Postgres transaction. The push handler drives the returned
|
|
633
700
|
* `StorageTransaction` imperatively (getRow/upsert/…/commit), but the
|
|
@@ -671,7 +738,7 @@ export class PostgresServerStorage {
|
|
|
671
738
|
if (this.#tables === undefined) {
|
|
672
739
|
throw new Error('ensureSchema(schema) must run before registered queries');
|
|
673
740
|
}
|
|
674
|
-
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.
|
|
741
|
+
const prepared = bindAuthoritativePartition(prepareAuthoritativeQuery(query.plan, query.params, query.tables, this.#tables), partition);
|
|
675
742
|
return this.#exec.transaction(async (client) => {
|
|
676
743
|
await client.query('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');
|
|
677
744
|
const result = await client.query(postgresPlaceholders(prepared.sql), prepared.params);
|
|
@@ -684,19 +751,38 @@ export class PostgresServerStorage {
|
|
|
684
751
|
};
|
|
685
752
|
});
|
|
686
753
|
}
|
|
754
|
+
async getPartitionLogEpoch(partition) {
|
|
755
|
+
const { rows } = await this.#exec.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1', [partition]);
|
|
756
|
+
return rows[0]?.log_epoch;
|
|
757
|
+
}
|
|
687
758
|
async getHorizonSeq(partition) {
|
|
688
759
|
const { rows } = await this.#exec.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
689
760
|
return rows[0] === undefined ? 0 : asNumber(rows[0].horizon_seq);
|
|
690
761
|
}
|
|
691
762
|
async setHorizonSeq(partition, seq) {
|
|
692
763
|
await this.#exec.query(`INSERT INTO sync_partitions(partition, horizon_seq) VALUES ($1,$2)
|
|
693
|
-
ON CONFLICT (partition) DO UPDATE SET horizon_seq=EXCLUDED.horizon_seq`, [partition, seq]);
|
|
764
|
+
ON CONFLICT (partition) DO UPDATE SET horizon_seq=GREATEST(sync_partitions.horizon_seq,EXCLUDED.horizon_seq)`, [partition, seq]);
|
|
694
765
|
}
|
|
695
|
-
async pruneCommitsThrough(partition,
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
766
|
+
async pruneCommitsThrough(partition, query) {
|
|
767
|
+
validateCommitPruneQuery(query);
|
|
768
|
+
return this.#exec.transaction(async (client) => {
|
|
769
|
+
await lockPartitionOn(client, partition);
|
|
770
|
+
const epoch = await client.query('SELECT log_epoch FROM sync_partition_registry WHERE partition=$1 FOR UPDATE', [partition]);
|
|
771
|
+
if (epoch.rows[0]?.log_epoch !== query.logEpoch)
|
|
772
|
+
throw new StorageQueryError('sync.storage.prune_epoch_mismatch');
|
|
773
|
+
const previous = await client.query('SELECT horizon_seq FROM sync_partitions WHERE partition=$1', [partition]);
|
|
774
|
+
const previousHorizonSeq = asNumber(previous.rows[0]?.horizon_seq);
|
|
775
|
+
const horizonSeq = Math.max(previousHorizonSeq, query.throughSeq);
|
|
776
|
+
await client.query('UPDATE sync_partitions SET horizon_seq=$2 WHERE partition=$1', [partition, horizonSeq]);
|
|
777
|
+
const removed = await client.query('DELETE FROM sync_commits WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
778
|
+
await client.query('DELETE FROM sync_changes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
779
|
+
await client.query('DELETE FROM sync_change_scopes WHERE partition=$1 AND commit_seq<=$2', [partition, horizonSeq]);
|
|
780
|
+
return {
|
|
781
|
+
previousHorizonSeq,
|
|
782
|
+
horizonSeq,
|
|
783
|
+
removedCommits: removed.rowCount,
|
|
784
|
+
};
|
|
785
|
+
});
|
|
700
786
|
}
|
|
701
787
|
async getCommitSeqBefore(partition, createdBeforeMs) {
|
|
702
788
|
const { rows } = await this.#exec.query('SELECT max(commit_seq) AS seq FROM sync_commits WHERE partition=$1 AND created_at_ms<$2', [partition, createdBeforeMs]);
|
|
@@ -978,33 +1064,40 @@ export class PostgresServerStorage {
|
|
|
978
1064
|
return scanRowsByIndexOn(this.#exec, this.table(query.table), partition, query);
|
|
979
1065
|
}
|
|
980
1066
|
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]);
|
|
1067
|
+
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
1068
|
const record = rows[0];
|
|
983
1069
|
if (record === undefined)
|
|
984
1070
|
return undefined;
|
|
985
1071
|
return {
|
|
986
1072
|
clientId: record.client_id,
|
|
987
1073
|
actorId: record.actor_id,
|
|
1074
|
+
wireVersion: asNumber(record.wire_version),
|
|
988
1075
|
cursor: asNumber(record.cursor),
|
|
989
1076
|
updatedAtMs: asNumber(record.updated_at_ms),
|
|
990
1077
|
subscriptions: asJson(record.subscriptions),
|
|
991
1078
|
};
|
|
992
1079
|
}
|
|
993
1080
|
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)
|
|
1081
|
+
await this.#exec.query(`INSERT INTO sync_clients(partition, client_id, actor_id, wire_version, cursor, subscriptions, updated_at_ms)
|
|
1082
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
|
996
1083
|
ON CONFLICT (partition, client_id) DO UPDATE
|
|
997
|
-
SET actor_id=EXCLUDED.actor_id,
|
|
1084
|
+
SET actor_id=EXCLUDED.actor_id, wire_version=EXCLUDED.wire_version,
|
|
1085
|
+
cursor=EXCLUDED.cursor,
|
|
998
1086
|
subscriptions=EXCLUDED.subscriptions,
|
|
999
1087
|
updated_at_ms=EXCLUDED.updated_at_ms`, [
|
|
1000
1088
|
partition,
|
|
1001
1089
|
record.clientId,
|
|
1002
1090
|
record.actorId,
|
|
1091
|
+
record.wireVersion,
|
|
1003
1092
|
record.cursor,
|
|
1004
1093
|
JSON.stringify(record.subscriptions),
|
|
1005
1094
|
record.updatedAtMs,
|
|
1006
1095
|
]);
|
|
1007
1096
|
}
|
|
1097
|
+
async getActiveClientCursorFloor(partition, cutoffMs) {
|
|
1098
|
+
const { rows } = await this.#exec.query('SELECT MIN(cursor) AS cursor FROM sync_clients WHERE partition=$1 AND updated_at_ms>=$2', [partition, cutoffMs]);
|
|
1099
|
+
return rows[0].cursor === null ? null : asNumber(rows[0].cursor);
|
|
1100
|
+
}
|
|
1008
1101
|
async listClientCursors(partition) {
|
|
1009
1102
|
const { rows } = await this.#exec.query('SELECT client_id, cursor, updated_at_ms FROM sync_clients WHERE partition=$1', [partition]);
|
|
1010
1103
|
return rows.map((r) => ({
|
|
@@ -1040,10 +1133,11 @@ export class PostgresServerStorage {
|
|
|
1040
1133
|
}
|
|
1041
1134
|
// -- admin/console read surface --------------------------------------------
|
|
1042
1135
|
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]);
|
|
1136
|
+
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
1137
|
return rows.map((r) => ({
|
|
1045
1138
|
clientId: r.client_id,
|
|
1046
1139
|
actorId: r.actor_id,
|
|
1140
|
+
wireVersion: asNumber(r.wire_version),
|
|
1047
1141
|
cursor: asNumber(r.cursor),
|
|
1048
1142
|
updatedAtMs: asNumber(r.updated_at_ms),
|
|
1049
1143
|
subscriptions: (typeof r.subscriptions === 'string'
|
|
@@ -1114,10 +1208,6 @@ export class PostgresServerStorage {
|
|
|
1114
1208
|
};
|
|
1115
1209
|
}
|
|
1116
1210
|
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);
|
|
1211
|
+
return (await this.listPartitionRegistry()).map((entry) => entry.partition);
|
|
1122
1212
|
}
|
|
1123
1213
|
}
|
package/dist/prune.d.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* least the newest `minRetainedCommits` commits are always retained.
|
|
8
8
|
*/
|
|
9
9
|
import { type SyncularServerEvents } from './events.js';
|
|
10
|
-
import type { ServerStorage } from './storage.js';
|
|
10
|
+
import type { CommitPruneQuery, ServerStorage } from './storage.js';
|
|
11
|
+
/** Shared validation for the built-in atomic pruning adapters. */
|
|
12
|
+
export declare function validateCommitPruneQuery(query: CommitPruneQuery): void;
|
|
11
13
|
export interface RetentionPolicy {
|
|
12
14
|
/** Active window for laggard cursors (default 14 days). */
|
|
13
15
|
readonly activeWindowMs: number;
|
package/dist/prune.js
CHANGED
|
@@ -7,6 +7,16 @@
|
|
|
7
7
|
* least the newest `minRetainedCommits` commits are always retained.
|
|
8
8
|
*/
|
|
9
9
|
import { emitEvent } from './events.js';
|
|
10
|
+
import { StorageQueryError } from './storage-errors.js';
|
|
11
|
+
/** Shared validation for the built-in atomic pruning adapters. */
|
|
12
|
+
export function validateCommitPruneQuery(query) {
|
|
13
|
+
if (!Number.isSafeInteger(query.throughSeq) ||
|
|
14
|
+
query.throughSeq < 0 ||
|
|
15
|
+
typeof query.logEpoch !== 'string' ||
|
|
16
|
+
query.logEpoch.length === 0) {
|
|
17
|
+
throw new StorageQueryError('sync.storage.invalid_prune_cursor');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
export const DEFAULT_RETENTION = {
|
|
11
21
|
activeWindowMs: 14 * 24 * 60 * 60 * 1000,
|
|
12
22
|
ageForceMs: 30 * 24 * 60 * 60 * 1000,
|
|
@@ -16,24 +26,18 @@ export const DEFAULT_RETENTION = {
|
|
|
16
26
|
export async function pruneCommitLog(options) {
|
|
17
27
|
const { storage, partition, nowMs } = options;
|
|
18
28
|
const policy = { ...DEFAULT_RETENTION, ...options.retention };
|
|
29
|
+
const logEpoch = await storage.getPartitionLogEpoch(partition);
|
|
30
|
+
if (logEpoch === undefined)
|
|
31
|
+
throw new StorageQueryError('sync.storage.partition_unregistered');
|
|
19
32
|
const maxSeq = await storage.getMaxCommitSeq(partition);
|
|
20
|
-
const
|
|
21
|
-
const activeCursors = cursors
|
|
22
|
-
.filter((c) => c.updatedAtMs >= nowMs - policy.activeWindowMs)
|
|
23
|
-
.map((c) => c.cursor);
|
|
24
|
-
const cursorFloor = activeCursors.length > 0
|
|
25
|
-
? Math.min(...activeCursors)
|
|
26
|
-
: Number.MAX_SAFE_INTEGER;
|
|
33
|
+
const cursorFloor = (await storage.getActiveClientCursorFloor(partition, nowMs - policy.activeWindowMs)) ?? Number.MAX_SAFE_INTEGER;
|
|
27
34
|
const forcedSeq = await storage.getCommitSeqBefore(partition, nowMs - policy.ageForceMs);
|
|
28
35
|
const retainFloor = maxSeq - policy.minRetainedCommits;
|
|
29
36
|
const target = Math.min(Math.max(cursorFloor, forcedSeq), retainFloor);
|
|
30
|
-
const current = await storage.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
await storage.setHorizonSeq(partition, horizon);
|
|
35
|
-
removedCommits = await storage.pruneCommitsThrough(partition, horizon);
|
|
36
|
-
}
|
|
37
|
+
const { previousHorizonSeq: current, horizonSeq: horizon, removedCommits, } = await storage.pruneCommitsThrough(partition, {
|
|
38
|
+
logEpoch,
|
|
39
|
+
throughSeq: Math.max(0, target),
|
|
40
|
+
});
|
|
37
41
|
const events = options.events;
|
|
38
42
|
if (events !== undefined) {
|
|
39
43
|
emitEvent(events, {
|
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
|
@@ -6,6 +6,8 @@ import { decodeRow, encodeRowsSegment, } from '@syncular/core';
|
|
|
6
6
|
import { clockOf, limitsOf } from './context.js';
|
|
7
7
|
import { scopeDigest } from './scopes.js';
|
|
8
8
|
import { issueSegmentUrl } from './signed-url.js';
|
|
9
|
+
// One artifact build per owning storage pair and complete immutable identity.
|
|
10
|
+
const imageBuilds = new WeakMap();
|
|
9
11
|
/**
|
|
10
12
|
* Resolve the §5.3 image builder: the host-injected one if present, else the
|
|
11
13
|
* in-tree `buildSqliteImage` on a Bun runtime (dynamic import so `bun:sqlite`
|
|
@@ -134,11 +136,12 @@ function segmentRefFrame(record, extra) {
|
|
|
134
136
|
* bootstrap-storm rule. Returns false when the table is not eligible
|
|
135
137
|
* (the rows lane takes over).
|
|
136
138
|
*/
|
|
137
|
-
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace) {
|
|
139
|
+
async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch) {
|
|
138
140
|
const { storage, segments, partition } = ctx;
|
|
139
141
|
const now = clockOf(ctx)();
|
|
140
142
|
const existing = await segments.find({
|
|
141
143
|
partition,
|
|
144
|
+
logEpoch,
|
|
142
145
|
table: plan.table.name,
|
|
143
146
|
schemaVersion: schema.version,
|
|
144
147
|
mediaType: 'sqlite',
|
|
@@ -176,54 +179,84 @@ async function* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trac
|
|
|
176
179
|
const buildImage = await resolveImageBuilder(ctx);
|
|
177
180
|
if (buildImage === undefined)
|
|
178
181
|
return false;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const rows = [...probe];
|
|
184
|
-
let afterRowId = probe[probe.length - 1]?.rowId ?? null;
|
|
185
|
-
for (;;) {
|
|
186
|
-
const scanned = await storage.scanRows(partition, {
|
|
187
|
-
table: plan.table.name,
|
|
188
|
-
scopeFilter: plan.effective,
|
|
189
|
-
afterRowId,
|
|
190
|
-
limit: 50_000,
|
|
191
|
-
});
|
|
192
|
-
rows.push(...scanned);
|
|
193
|
-
const last = scanned[scanned.length - 1];
|
|
194
|
-
if (scanned.length < 50_000 || last === undefined)
|
|
195
|
-
break;
|
|
196
|
-
afterRowId = last.rowId;
|
|
182
|
+
let stores = imageBuilds.get(storage);
|
|
183
|
+
if (stores === undefined) {
|
|
184
|
+
stores = new WeakMap();
|
|
185
|
+
imageBuilds.set(storage, stores);
|
|
197
186
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
});
|
|
205
|
-
const record = await segments.put({
|
|
187
|
+
let builds = stores.get(segments);
|
|
188
|
+
if (builds === undefined) {
|
|
189
|
+
builds = new Map();
|
|
190
|
+
stores.set(segments, builds);
|
|
191
|
+
}
|
|
192
|
+
const identity = {
|
|
206
193
|
partition,
|
|
194
|
+
logEpoch,
|
|
207
195
|
table: plan.table.name,
|
|
208
196
|
schemaVersion: schema.version,
|
|
209
197
|
mediaType: 'sqlite',
|
|
210
198
|
scopeDigest: digest,
|
|
211
199
|
asOfCommitSeq: asOf,
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
200
|
+
};
|
|
201
|
+
const key = JSON.stringify(identity);
|
|
202
|
+
let building = builds.get(key);
|
|
203
|
+
let builtHere = false;
|
|
204
|
+
if (building === undefined) {
|
|
205
|
+
building = (async () => {
|
|
206
|
+
// Another request can finish while this one's eligibility probe awaits.
|
|
207
|
+
const cached = await segments.find(identity, clockOf(ctx)());
|
|
208
|
+
if (cached !== undefined)
|
|
209
|
+
return cached;
|
|
210
|
+
builtHere = true;
|
|
211
|
+
let rowCount = 0;
|
|
212
|
+
const bytes = await buildImage({
|
|
213
|
+
table: plan.table,
|
|
214
|
+
schemaVersion: schema.version,
|
|
215
|
+
asOfCommitSeq: asOf,
|
|
216
|
+
scopeDigest: digest,
|
|
217
|
+
rowBatches: (async function* () {
|
|
218
|
+
rowCount += probe.length;
|
|
219
|
+
yield probe;
|
|
220
|
+
let afterRowId = probe[probe.length - 1].rowId;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const rows = await storage.scanRows(partition, {
|
|
223
|
+
table: plan.table.name,
|
|
224
|
+
scopeFilter: plan.effective,
|
|
225
|
+
afterRowId,
|
|
226
|
+
limit: 5_000,
|
|
227
|
+
});
|
|
228
|
+
rowCount += rows.length;
|
|
229
|
+
yield rows;
|
|
230
|
+
const last = rows[rows.length - 1];
|
|
231
|
+
if (rows.length < 5_000 || last === undefined)
|
|
232
|
+
break;
|
|
233
|
+
afterRowId = last.rowId;
|
|
234
|
+
}
|
|
235
|
+
})(),
|
|
236
|
+
});
|
|
237
|
+
return segments.put({ ...identity, rowCount, rowCursor: null, nextRowCursor: null }, bytes, clockOf(ctx)());
|
|
238
|
+
})();
|
|
239
|
+
builds.set(key, building);
|
|
240
|
+
}
|
|
241
|
+
let record;
|
|
242
|
+
try {
|
|
243
|
+
record = await building;
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
if (builds.get(key) === building)
|
|
247
|
+
builds.delete(key);
|
|
248
|
+
}
|
|
216
249
|
trace?.segments.push({
|
|
217
250
|
mediaType: 'sqlite',
|
|
218
251
|
delivery: 'ref',
|
|
219
|
-
origin: 'built',
|
|
252
|
+
origin: builtHere ? 'built' : 'reused',
|
|
220
253
|
bytes: record.byteLength,
|
|
221
254
|
rows: record.rowCount,
|
|
222
255
|
});
|
|
223
|
-
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest,
|
|
256
|
+
yield segmentRefFrame(record, await signedUrlFields(ctx, limits, record.segmentId, digest, clockOf(ctx)()));
|
|
224
257
|
return true;
|
|
225
258
|
}
|
|
226
|
-
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace) {
|
|
259
|
+
async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCursor, trace, logEpoch) {
|
|
227
260
|
const { storage, segments, partition } = ctx;
|
|
228
261
|
const serverLimits = limitsOf(ctx);
|
|
229
262
|
const digest = await scopeDigest(plan.effective);
|
|
@@ -237,7 +270,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
237
270
|
if ((limits.accept & ACCEPT_SQLITE) !== 0 &&
|
|
238
271
|
startRowCursor === null &&
|
|
239
272
|
plan.table.encryptedColumnIndices.length === 0) {
|
|
240
|
-
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace);
|
|
273
|
+
const imaged = yield* sqliteImageSegment(ctx, schema, limits, plan, asOf, digest, trace, logEpoch);
|
|
241
274
|
if (imaged)
|
|
242
275
|
return { complete: true, rowCursor: null };
|
|
243
276
|
}
|
|
@@ -281,6 +314,7 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
281
314
|
else {
|
|
282
315
|
const record = await segments.put({
|
|
283
316
|
partition,
|
|
317
|
+
logEpoch,
|
|
284
318
|
table: plan.table.name,
|
|
285
319
|
schemaVersion: schema.version,
|
|
286
320
|
mediaType: 'rows',
|
|
@@ -309,8 +343,11 @@ async function* bootstrapSegments(ctx, schema, limits, plan, asOf, startRowCurso
|
|
|
309
343
|
* Produce the `SUB_START … SUB_END` section for one subscription (§1.6),
|
|
310
344
|
* returning the cursor recorded for the retention watermark (§4.5).
|
|
311
345
|
*/
|
|
312
|
-
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace) {
|
|
346
|
+
export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, horizonSeq, trace, logEpoch) {
|
|
313
347
|
const sub = plan.frame;
|
|
348
|
+
if (logEpoch === undefined || logEpoch.length === 0) {
|
|
349
|
+
throw new Error('subscriptionSection requires a non-empty log epoch');
|
|
350
|
+
}
|
|
314
351
|
if (plan.status === 'revoked') {
|
|
315
352
|
yield {
|
|
316
353
|
type: 'SUB_START',
|
|
@@ -355,7 +392,7 @@ export async function* subscriptionSection(ctx, schema, limits, plan, maxSeq, ho
|
|
|
355
392
|
effectiveScopes: plan.effective,
|
|
356
393
|
bootstrap: true,
|
|
357
394
|
};
|
|
358
|
-
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace);
|
|
395
|
+
const outcome = yield* bootstrapSegments(ctx, schema, limits, plan, asOf, startCursor, trace, logEpoch);
|
|
359
396
|
if (outcome.complete) {
|
|
360
397
|
yield { type: 'SUB_END', nextCursor: asOf };
|
|
361
398
|
}
|
package/dist/push.js
CHANGED
|
@@ -98,7 +98,7 @@ async function runValidator(validators, table, op, rowId, values, storedValues,
|
|
|
98
98
|
// §6.7: a non-ValidationRejection throw is still a rejection, mapped to
|
|
99
99
|
// the generic server-side constraint code (§10.2) — the validator's
|
|
100
100
|
// failure never crashes the request or leaks its message as a code.
|
|
101
|
-
return errorRecord(opIndex, 'sync.constraint_violation',
|
|
101
|
+
return errorRecord(opIndex, 'sync.constraint_violation', 'write validator failed');
|
|
102
102
|
}
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
@@ -124,7 +124,7 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
124
124
|
continue; // NULL clear or absent
|
|
125
125
|
const merger = mergers?.[crdtType];
|
|
126
126
|
if (merger === undefined) {
|
|
127
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
127
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'no CRDT merger registered');
|
|
128
128
|
}
|
|
129
129
|
const storedRaw = storedValues?.[index];
|
|
130
130
|
const stored = storedRaw instanceof Uint8Array ? storedRaw : null;
|
|
@@ -132,8 +132,8 @@ async function mergeCrdtColumns(table, values, storedValues, opIndex, mergers) {
|
|
|
132
132
|
try {
|
|
133
133
|
merged = await merger(stored, incoming);
|
|
134
134
|
}
|
|
135
|
-
catch
|
|
136
|
-
return errorRecord(opIndex, 'sync.crdt_merge_failed',
|
|
135
|
+
catch {
|
|
136
|
+
return errorRecord(opIndex, 'sync.crdt_merge_failed', 'CRDT merger failed');
|
|
137
137
|
}
|
|
138
138
|
values[index] = merged;
|
|
139
139
|
changed = true;
|
|
@@ -452,7 +452,7 @@ async function runCommitValidator(validator, tx, schema, clientId, clientCommitI
|
|
|
452
452
|
if (error instanceof ValidationRejection) {
|
|
453
453
|
return errorRecord(operations[0]?.opIndex ?? 0, error.code, error.message, false, error.details);
|
|
454
454
|
}
|
|
455
|
-
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation',
|
|
455
|
+
return errorRecord(operations[0]?.opIndex ?? 0, 'sync.constraint_violation', 'whole-commit validator failed');
|
|
456
456
|
}
|
|
457
457
|
return undefined;
|
|
458
458
|
}
|
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;
|