@opendatalabs/personal-server-ts-server 1.34.2 → 1.35.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/dist/api-auth.d.ts +1 -0
- package/dist/api-auth.d.ts.map +1 -1
- package/dist/api-auth.js.map +1 -1
- package/dist/api.d.ts +3 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +3 -0
- package/dist/api.js.map +1 -1
- package/dist/app.d.ts +14 -2
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +64 -8
- package/dist/app.js.map +1 -1
- package/dist/bootstrap.d.ts +19 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +87 -4
- package/dist/bootstrap.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- package/dist/enclave-main.d.ts.map +1 -1
- package/dist/enclave-main.js +0 -2
- package/dist/enclave-main.js.map +1 -1
- package/dist/mcp/durable-state.d.ts +2 -1
- package/dist/mcp/durable-state.d.ts.map +1 -1
- package/dist/mcp/durable-state.js +35 -1
- package/dist/mcp/durable-state.js.map +1 -1
- package/dist/pdpp/bootstrap.d.ts.map +1 -1
- package/dist/pdpp/bootstrap.js +1 -0
- package/dist/pdpp/bootstrap.js.map +1 -1
- package/dist/pdpp/records-bootstrap.d.ts +47 -1
- package/dist/pdpp/records-bootstrap.d.ts.map +1 -1
- package/dist/pdpp/records-bootstrap.js +78 -2
- package/dist/pdpp/records-bootstrap.js.map +1 -1
- package/dist/pdpp/replica-authorization.d.ts +35 -0
- package/dist/pdpp/replica-authorization.d.ts.map +1 -0
- package/dist/pdpp/replica-authorization.js +234 -0
- package/dist/pdpp/replica-authorization.js.map +1 -0
- package/dist/pending-blob-deletions.d.ts +1 -0
- package/dist/pending-blob-deletions.d.ts.map +1 -1
- package/dist/pending-blob-deletions.js +8 -4
- package/dist/pending-blob-deletions.js.map +1 -1
- package/dist/routes/data.d.ts.map +1 -1
- package/dist/routes/data.js +5 -0
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/mcp.d.ts +18 -2
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +111 -11
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/oura.d.ts +6 -0
- package/dist/routes/oura.d.ts.map +1 -0
- package/dist/routes/oura.js +272 -0
- package/dist/routes/oura.js.map +1 -0
- package/dist/routes/pdpp-auth-query-keys.d.ts +7 -0
- package/dist/routes/pdpp-auth-query-keys.d.ts.map +1 -0
- package/dist/routes/pdpp-auth-query-keys.js +25 -0
- package/dist/routes/pdpp-auth-query-keys.js.map +1 -0
- package/dist/routes/pdpp-auth.d.ts +3 -1
- package/dist/routes/pdpp-auth.d.ts.map +1 -1
- package/dist/routes/pdpp-auth.js +126 -45
- package/dist/routes/pdpp-auth.js.map +1 -1
- package/dist/routes/pdpp-blobs.d.ts +13 -1
- package/dist/routes/pdpp-blobs.d.ts.map +1 -1
- package/dist/routes/pdpp-blobs.js +100 -65
- package/dist/routes/pdpp-blobs.js.map +1 -1
- package/dist/routes/pdpp-connections.d.ts.map +1 -1
- package/dist/routes/pdpp-connections.js +24 -1
- package/dist/routes/pdpp-connections.js.map +1 -1
- package/dist/routes/pdpp-instance-bindings.d.ts +3 -0
- package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -1
- package/dist/routes/pdpp-instance-bindings.js +63 -0
- package/dist/routes/pdpp-instance-bindings.js.map +1 -1
- package/dist/routes/pdpp-record-query.d.ts +19 -0
- package/dist/routes/pdpp-record-query.d.ts.map +1 -0
- package/dist/routes/pdpp-record-query.js +51 -0
- package/dist/routes/pdpp-record-query.js.map +1 -0
- package/dist/routes/pdpp-records.d.ts +12 -1
- package/dist/routes/pdpp-records.d.ts.map +1 -1
- package/dist/routes/pdpp-records.js +120 -233
- package/dist/routes/pdpp-records.js.map +1 -1
- package/dist/routes/pdpp-replica-quarantine-fixture.d.ts +65 -0
- package/dist/routes/pdpp-replica-quarantine-fixture.d.ts.map +1 -0
- package/dist/routes/pdpp-replica-quarantine-fixture.js +389 -0
- package/dist/routes/pdpp-replica-quarantine-fixture.js.map +1 -0
- package/dist/storage/claude-method-transition.d.ts +12 -0
- package/dist/storage/claude-method-transition.d.ts.map +1 -0
- package/dist/storage/claude-method-transition.js +139 -0
- package/dist/storage/claude-method-transition.js.map +1 -0
- package/dist/storage/import-operations.d.ts +16 -0
- package/dist/storage/import-operations.d.ts.map +1 -1
- package/dist/storage/import-operations.js +54 -1
- package/dist/storage/import-operations.js.map +1 -1
- package/dist/storage/index-schema.d.ts +1 -1
- package/dist/storage/index-schema.d.ts.map +1 -1
- package/dist/storage/index-schema.js +22 -5
- package/dist/storage/index-schema.js.map +1 -1
- package/dist/storage/legacy-scope-authority.d.ts.map +1 -1
- package/dist/storage/legacy-scope-authority.js +83 -11
- package/dist/storage/legacy-scope-authority.js.map +1 -1
- package/dist/storage/node-data-storage.d.ts.map +1 -1
- package/dist/storage/node-data-storage.js +58 -2
- package/dist/storage/node-data-storage.js.map +1 -1
- package/dist/storage/pdpp-binary-chunks.d.ts +120 -0
- package/dist/storage/pdpp-binary-chunks.d.ts.map +1 -0
- package/dist/storage/pdpp-binary-chunks.js +480 -0
- package/dist/storage/pdpp-binary-chunks.js.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts +96 -15
- package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -1
- package/dist/storage/pdpp-records-sqlite-store.js +616 -211
- package/dist/storage/pdpp-records-sqlite-store.js.map +1 -1
- package/dist/storage/pdpp-replica-canonical-port.d.ts +202 -0
- package/dist/storage/pdpp-replica-canonical-port.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-canonical-port.js +451 -0
- package/dist/storage/pdpp-replica-canonical-port.js.map +1 -0
- package/dist/storage/pdpp-replica-coordinator-state.d.ts +16 -0
- package/dist/storage/pdpp-replica-coordinator-state.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-coordinator-state.js +25 -0
- package/dist/storage/pdpp-replica-coordinator-state.js.map +1 -0
- package/dist/storage/pdpp-replica-copies.d.ts +81 -0
- package/dist/storage/pdpp-replica-copies.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-copies.js +59 -0
- package/dist/storage/pdpp-replica-copies.js.map +1 -0
- package/dist/storage/pdpp-replica-reconciler.d.ts +96 -0
- package/dist/storage/pdpp-replica-reconciler.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-reconciler.js +710 -0
- package/dist/storage/pdpp-replica-reconciler.js.map +1 -0
- package/dist/storage/pdpp-replica-sql.d.ts +13 -0
- package/dist/storage/pdpp-replica-sql.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-sql.js +43 -0
- package/dist/storage/pdpp-replica-sql.js.map +1 -0
- package/dist/storage/pdpp-replica-staging.d.ts +36 -0
- package/dist/storage/pdpp-replica-staging.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-staging.js +202 -0
- package/dist/storage/pdpp-replica-staging.js.map +1 -0
- package/dist/storage/pdpp-replica-visibility-view.d.ts +64 -0
- package/dist/storage/pdpp-replica-visibility-view.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-visibility-view.js +1397 -0
- package/dist/storage/pdpp-replica-visibility-view.js.map +1 -0
- package/dist/storage/pdpp-replica-writer-identity.d.ts +64 -0
- package/dist/storage/pdpp-replica-writer-identity.d.ts.map +1 -0
- package/dist/storage/pdpp-replica-writer-identity.js +81 -0
- package/dist/storage/pdpp-replica-writer-identity.js.map +1 -0
- package/dist/storage/pdpp-replication-door.d.ts +60 -0
- package/dist/storage/pdpp-replication-door.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-door.js +203 -0
- package/dist/storage/pdpp-replication-door.js.map +1 -0
- package/dist/storage/pdpp-replication-host.d.ts +67 -0
- package/dist/storage/pdpp-replication-host.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-host.js +159 -0
- package/dist/storage/pdpp-replication-host.js.map +1 -0
- package/dist/storage/pdpp-replication-memory.fixture.d.ts +2 -0
- package/dist/storage/pdpp-replication-memory.fixture.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-memory.fixture.js +267 -0
- package/dist/storage/pdpp-replication-memory.fixture.js.map +1 -0
- package/dist/storage/pdpp-replication-preparation.d.ts +3 -0
- package/dist/storage/pdpp-replication-preparation.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-preparation.js +3 -0
- package/dist/storage/pdpp-replication-preparation.js.map +1 -0
- package/dist/storage/pdpp-replication-promotion-memory.fixture.d.ts +2 -0
- package/dist/storage/pdpp-replication-promotion-memory.fixture.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-promotion-memory.fixture.js +65 -0
- package/dist/storage/pdpp-replication-promotion-memory.fixture.js.map +1 -0
- package/dist/storage/pdpp-replication-receiver.d.ts +230 -0
- package/dist/storage/pdpp-replication-receiver.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-receiver.js +950 -0
- package/dist/storage/pdpp-replication-receiver.js.map +1 -0
- package/dist/storage/pdpp-replication-sqlite.d.ts +95 -0
- package/dist/storage/pdpp-replication-sqlite.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-sqlite.js +615 -0
- package/dist/storage/pdpp-replication-sqlite.js.map +1 -0
- package/dist/storage/pdpp-replication-transport.d.ts +39 -0
- package/dist/storage/pdpp-replication-transport.d.ts.map +1 -0
- package/dist/storage/pdpp-replication-transport.js +196 -0
- package/dist/storage/pdpp-replication-transport.js.map +1 -0
- package/dist/storage/schema-versions.d.ts +5 -0
- package/dist/storage/schema-versions.d.ts.map +1 -0
- package/dist/storage/schema-versions.js +26 -0
- package/dist/storage/schema-versions.js.map +1 -0
- package/dist/storage/strava-method-transition.d.ts +18 -0
- package/dist/storage/strava-method-transition.d.ts.map +1 -0
- package/dist/storage/strava-method-transition.js +209 -0
- package/dist/storage/strava-method-transition.js.map +1 -0
- package/dist/ui/ps-lite-debug.js +154947 -124896
- package/package.json +3 -3
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
import { createReplicaVisibilityView } from "./pdpp-replica-visibility-view.js";
|
|
2
|
+
import { binaryLength, clearMalformedManifest, binaryWriter, verifyBinary, copyBinary, copyBinaryToCanonical, deleteBinary, readBinaryChunks, } from "./pdpp-binary-chunks.js";
|
|
3
|
+
import { createReplicaCopies, REPLICA_REFETCH_BUDGET, ReplicaCopyRefused, } from "./pdpp-replica-copies.js";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { canonicalJson, declarationShape, replicaEnvelopeSchema, } from "@opendatalabs/personal-server-ts-core/replication/envelope";
|
|
6
|
+
import { blobReady } from "@opendatalabs/personal-server-ts-core/replication/visibility-engine";
|
|
7
|
+
import { createReplicaReconciler, } from "./pdpp-replica-reconciler.js";
|
|
8
|
+
import { replicaOperationDigest } from "./pdpp-replication-sqlite.js";
|
|
9
|
+
import { replicaStateVisible, } from "@opendatalabs/personal-server-ts-core/replication/visibility-policy";
|
|
10
|
+
import { CursorExpiredError, decodeCursor, encodeCursor, validateCursor, } from "@opendatalabs/personal-server-ts-core/storage/pdpp-records";
|
|
11
|
+
export const RECEIVER_MIGRATION = `
|
|
12
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_received (operation_json TEXT PRIMARY KEY, digest TEXT NOT NULL);
|
|
13
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_received_no_update BEFORE UPDATE ON pdpp_replica_received BEGIN SELECT RAISE(ABORT, 'received operations are append-only'); END;
|
|
14
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_received_no_delete BEFORE DELETE ON pdpp_replica_received BEGIN SELECT RAISE(ABORT, 'received operations are append-only'); END;
|
|
15
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_quarantined_keys (instance TEXT NOT NULL, generation INTEGER NOT NULL, stream TEXT NOT NULL, record_key TEXT NOT NULL, PRIMARY KEY(instance,generation,stream,record_key));
|
|
16
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_quarantined_connections (instance TEXT PRIMARY KEY);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_received_bytes (blob_id TEXT PRIMARY KEY, bytes BLOB NOT NULL);
|
|
18
|
+
`;
|
|
19
|
+
/**
|
|
20
|
+
* Replica integrity facts. Only replica tables: no main table changes.
|
|
21
|
+
* - integrity fences: how many times replica evidence hid data of a
|
|
22
|
+
* connection (key or connection quarantine, binding conflict). With the
|
|
23
|
+
* binding's reset_clock it forms the connection's integrity epoch.
|
|
24
|
+
* - binding conflicts: a connection whose evidence names another source or
|
|
25
|
+
* method than its local mapping, or two sources. Append-only: later
|
|
26
|
+
* evidence, equivocation included, can never erase the fact.
|
|
27
|
+
*/
|
|
28
|
+
export const REPLICA_INTEGRITY_MIGRATION = `
|
|
29
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_integrity_fences (instance TEXT PRIMARY KEY, fences INTEGER NOT NULL);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS pdpp_replica_binding_conflicts (instance TEXT PRIMARY KEY, reason TEXT NOT NULL, evidence_json TEXT NOT NULL);
|
|
31
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_binding_conflicts_no_update BEFORE UPDATE ON pdpp_replica_binding_conflicts BEGIN SELECT RAISE(ABORT, 'binding conflicts are append-only'); END;
|
|
32
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_binding_conflicts_no_delete BEFORE DELETE ON pdpp_replica_binding_conflicts BEGIN SELECT RAISE(ABORT, 'binding conflicts are append-only'); END;
|
|
33
|
+
`;
|
|
34
|
+
/**
|
|
35
|
+
* Invariant: removing a quarantine fact un-hides data, so it must raise the
|
|
36
|
+
* connection's integrity epoch in the same transaction, exactly as adding
|
|
37
|
+
* one does (fenceReplicaConnection). These triggers make that structural:
|
|
38
|
+
* any DELETE of a key or connection fact fences its connection, so no
|
|
39
|
+
* future removal path can forget it. pdpp-replica-invariants.test.ts (1).
|
|
40
|
+
*/
|
|
41
|
+
export const REPLICA_FACT_REMOVAL_MIGRATION = `
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_quarantined_keys_removal_fence AFTER DELETE ON pdpp_replica_quarantined_keys BEGIN INSERT INTO pdpp_replica_integrity_fences VALUES (OLD.instance, 1) ON CONFLICT(instance) DO UPDATE SET fences = fences + 1; END;
|
|
43
|
+
CREATE TRIGGER IF NOT EXISTS pdpp_quarantined_connections_removal_fence AFTER DELETE ON pdpp_replica_quarantined_connections BEGIN INSERT INTO pdpp_replica_integrity_fences VALUES (OLD.instance, 1) ON CONFLICT(instance) DO UPDATE SET fences = fences + 1; END;
|
|
44
|
+
`;
|
|
45
|
+
/**
|
|
46
|
+
* Internal port, absent from connector routes. Bootstrap builds it only through
|
|
47
|
+
* `createNodeReplication`, behind `replication.enabled`. The host
|
|
48
|
+
* must verify a replica-specific credential before returning its owner. A payload,
|
|
49
|
+
* a collector credential, and a source declaration are never authentication.
|
|
50
|
+
* Connection ids are the registered canonical instance ids (the export profile).
|
|
51
|
+
*/
|
|
52
|
+
export function createReplicaReceiver(deps) {
|
|
53
|
+
const { db, store, owner } = deps;
|
|
54
|
+
let pendingEnabled = deps.enabled === true;
|
|
55
|
+
db.exec(`CREATE TABLE IF NOT EXISTS pdpp_replica_pending_batches (
|
|
56
|
+
id TEXT PRIMARY KEY, envelope_json TEXT NOT NULL, blobs_json TEXT NOT NULL,
|
|
57
|
+
state TEXT NOT NULL CHECK(state IN ('pending','confirmed','quarantined')), reason TEXT
|
|
58
|
+
); CREATE TABLE IF NOT EXISTS pdpp_replica_pending_bytes (
|
|
59
|
+
batch_id TEXT NOT NULL, blob_id TEXT NOT NULL, bytes BLOB NOT NULL, PRIMARY KEY(batch_id,blob_id)
|
|
60
|
+
)`);
|
|
61
|
+
const pendingBlobKey = (batchId, blobId) => canonicalJson([batchId, blobId]);
|
|
62
|
+
function authorize(credential) {
|
|
63
|
+
if (!owner || deps.authenticate(credential) !== owner)
|
|
64
|
+
throw new Error("replica authority required for owner");
|
|
65
|
+
const settings = db
|
|
66
|
+
.prepare("SELECT config_json FROM pdpp_replica_settings WHERE id = 1")
|
|
67
|
+
.get();
|
|
68
|
+
if (!settings || JSON.parse(settings.config_json).owner !== owner)
|
|
69
|
+
throw new Error("same-owner replication baseline required");
|
|
70
|
+
}
|
|
71
|
+
const binding = (id) => db
|
|
72
|
+
.prepare("SELECT * FROM pdpp_instance_binding WHERE instance = ?")
|
|
73
|
+
.get(id);
|
|
74
|
+
function validateMapping(operation) {
|
|
75
|
+
const local = binding(operation.connectionId);
|
|
76
|
+
if (local?.source_id &&
|
|
77
|
+
(local.source_id !== operation.sourceId ||
|
|
78
|
+
local.method !== operation.methodId))
|
|
79
|
+
throw new Error("replica connection source or method mismatch");
|
|
80
|
+
}
|
|
81
|
+
/** Hide one key. Raises the connection's integrity epoch only when newly hidden. */
|
|
82
|
+
function quarantineKey(instance, generation, stream, recordKey) {
|
|
83
|
+
if (db
|
|
84
|
+
.prepare("INSERT OR IGNORE INTO pdpp_replica_quarantined_keys VALUES (?, ?, ?, ?)")
|
|
85
|
+
.run(instance, generation, stream, recordKey).changes > 0)
|
|
86
|
+
store.fenceReplicaConnection(instance);
|
|
87
|
+
}
|
|
88
|
+
/** Hide a connection. Returns true, with a raised epoch, only when newly hidden. */
|
|
89
|
+
function quarantineConnection(instance) {
|
|
90
|
+
const hidden = db
|
|
91
|
+
.prepare("INSERT OR IGNORE INTO pdpp_replica_quarantined_connections VALUES (?)")
|
|
92
|
+
.run(instance).changes > 0;
|
|
93
|
+
if (hidden)
|
|
94
|
+
store.fenceReplicaConnection(instance);
|
|
95
|
+
return hidden;
|
|
96
|
+
}
|
|
97
|
+
const hash = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
98
|
+
function verifiedBytes(blob) {
|
|
99
|
+
const namespace = binaryLength(db, "received", blob.blobId) === undefined
|
|
100
|
+
? "canonical"
|
|
101
|
+
: "received";
|
|
102
|
+
return blob.blobId === blob.sha256 &&
|
|
103
|
+
verifyBinary(db, namespace, blob.blobId, blob.sha256, blob.sizeBytes)
|
|
104
|
+
? namespace
|
|
105
|
+
: undefined;
|
|
106
|
+
}
|
|
107
|
+
/** Blob ids whose bytes are verified against a descriptor, for `blobReady`. */
|
|
108
|
+
function verifiedBlobs(blobs) {
|
|
109
|
+
return new Set(blobs.flatMap((blob) => (verifiedBytes(blob) ? [blob.blobId] : [])));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Persist the bytes and claims of an upsert `blobReady` accepted: metadata,
|
|
113
|
+
* the canonical copy, and the connection's claim.
|
|
114
|
+
*/
|
|
115
|
+
function persistBlobs(operation) {
|
|
116
|
+
for (const blob of operation.blobs) {
|
|
117
|
+
const meta = store.getBlobMeta(blob.blobId);
|
|
118
|
+
if (meta &&
|
|
119
|
+
(meta.sha256 !== blob.sha256.slice(7) ||
|
|
120
|
+
meta.mimeType !== blob.mimeType ||
|
|
121
|
+
meta.sizeBytes !== blob.sizeBytes))
|
|
122
|
+
throw new Error("replica blob metadata conflict");
|
|
123
|
+
db.prepare("INSERT OR IGNORE INTO pdpp_blobs VALUES (?, ?, ?, ?)").run(blob.blobId, blob.mimeType, blob.sizeBytes, blob.sha256.slice(7));
|
|
124
|
+
// The received copy stays: it is custody for every retained
|
|
125
|
+
// operation that names the blob, applied or withheld, and canonical
|
|
126
|
+
// collection does not count those operations (host contract §6).
|
|
127
|
+
if (binaryLength(db, "canonical", blob.blobId) !== undefined) {
|
|
128
|
+
if (!verifyBinary(db, "canonical", blob.blobId, blob.sha256, blob.sizeBytes))
|
|
129
|
+
throw new Error("corrupt canonical blob bytes");
|
|
130
|
+
}
|
|
131
|
+
else
|
|
132
|
+
copyBinaryToCanonical(db, verifiedBytes(blob), blob.blobId);
|
|
133
|
+
db.prepare("INSERT INTO pdpp_blob_claims VALUES (?, ?, ?) ON CONFLICT(blob_id, instance) DO UPDATE SET generation = excluded.generation").run(blob.blobId, operation.connectionId, operation.generation);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function write(instance, stream, key, operation) {
|
|
137
|
+
const current = db
|
|
138
|
+
.prepare("SELECT * FROM pdpp_records WHERE instance = ? AND stream = ? AND record_key = ?")
|
|
139
|
+
.get(instance, stream, key);
|
|
140
|
+
// Hiding a row that is already a tombstone keeps it. Rewriting it would
|
|
141
|
+
// bump the version and replace the delete's deletedAt with emittedAt. A
|
|
142
|
+
// later import for another connection re-plans this one (oracle D2).
|
|
143
|
+
if (!operation && current?.data === null)
|
|
144
|
+
return;
|
|
145
|
+
const data = operation?.kind === "upsert" ? canonicalJson(operation.data) : null;
|
|
146
|
+
const emittedAt = operation?.emittedAt ?? current?.emitted_at;
|
|
147
|
+
if (!emittedAt)
|
|
148
|
+
return;
|
|
149
|
+
const deletedAt = operation?.kind === "delete"
|
|
150
|
+
? operation.deletedAt
|
|
151
|
+
: data === null
|
|
152
|
+
? emittedAt
|
|
153
|
+
: null;
|
|
154
|
+
if (current &&
|
|
155
|
+
(current.data === null
|
|
156
|
+
? null
|
|
157
|
+
: canonicalJson(JSON.parse(current.data))) === data &&
|
|
158
|
+
current.emitted_at === emittedAt &&
|
|
159
|
+
current.deleted_at === deletedAt)
|
|
160
|
+
return;
|
|
161
|
+
const version = (current?.version ?? 0) + 1;
|
|
162
|
+
const { value: seq } = db
|
|
163
|
+
.prepare("UPDATE pdpp_write_clock SET value = value + 1 WHERE id = 1 RETURNING value")
|
|
164
|
+
.get();
|
|
165
|
+
const blobId = operation?.kind === "upsert"
|
|
166
|
+
? (operation.blobs[0]?.blobId ?? null)
|
|
167
|
+
: null;
|
|
168
|
+
db.prepare("INSERT INTO pdpp_records (instance,stream,record_key,data,version,emitted_at,emitted_key,deleted,deleted_at,blob_id) VALUES (?, ?, ?, ?, ?, ?, pdpp_time_key(?), ?, ?, ?) ON CONFLICT(instance,stream,record_key) DO UPDATE SET data=excluded.data, version=excluded.version, emitted_at=excluded.emitted_at, emitted_key=excluded.emitted_key, deleted=excluded.deleted, deleted_at=excluded.deleted_at, blob_id=excluded.blob_id").run(instance, stream, key, data, version, emittedAt, emittedAt, data === null ? 1 : 0, deletedAt, blobId);
|
|
169
|
+
db.prepare("INSERT INTO pdpp_record_changes (instance,stream,record_key,version,data,emitted_at,emitted_key,deleted,deleted_at,written_at) VALUES (?, ?, ?, ?, ?, ?, pdpp_time_key(?), ?, ?, ?)").run(instance, stream, key, version, data, emittedAt, emittedAt, data === null ? 1 : 0, deletedAt, seq);
|
|
170
|
+
db.prepare("DELETE FROM pdpp_stream_completion WHERE instance = ? AND stream = ?").run(instance, stream);
|
|
171
|
+
if (data === null)
|
|
172
|
+
db.prepare("DELETE FROM pdpp_record_arrival WHERE instance = ? AND stream = ? AND record_key = ?").run(instance, stream, key);
|
|
173
|
+
else
|
|
174
|
+
db.prepare("INSERT INTO pdpp_record_arrival VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(arrival), 0) + 1 FROM pdpp_record_arrival WHERE instance = ?)) ON CONFLICT(instance,stream,record_key) DO UPDATE SET generation=excluded.generation,arrival=excluded.arrival").run(instance, stream, key, operation.generation, instance);
|
|
175
|
+
db.prepare("UPDATE pdpp_record_arrival_clock SET value = value + 1 WHERE id = 1").run();
|
|
176
|
+
}
|
|
177
|
+
function isRecordQuarantined(instance, stream, key) {
|
|
178
|
+
return !db
|
|
179
|
+
.prepare(`SELECT 1 FROM (SELECT ? AS instance,? AS stream,? AS record_key) r WHERE ${store.unquarantinedRecordSql("r")}`)
|
|
180
|
+
.get(instance, stream, key);
|
|
181
|
+
}
|
|
182
|
+
const reconciler = createReplicaReconciler({
|
|
183
|
+
db,
|
|
184
|
+
owner,
|
|
185
|
+
declarations: deps.declarations,
|
|
186
|
+
effects: {
|
|
187
|
+
binding,
|
|
188
|
+
write,
|
|
189
|
+
quarantineKey,
|
|
190
|
+
quarantineConnection,
|
|
191
|
+
isRecordQuarantined,
|
|
192
|
+
verifiedBlobs,
|
|
193
|
+
persistBlobs,
|
|
194
|
+
markBindingConflict: (input) => store.markReplicaBindingConflict(input),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const view = createReplicaVisibilityView({
|
|
198
|
+
db,
|
|
199
|
+
owner,
|
|
200
|
+
visibilityPolicy: deps.visibilityPolicy,
|
|
201
|
+
declarations: deps.declarations,
|
|
202
|
+
blobsValid: previewBlobsValid,
|
|
203
|
+
unquarantinedRecordSql: store.unquarantinedRecordSql,
|
|
204
|
+
integrityEpoch: store.replicaIntegrityEpoch,
|
|
205
|
+
});
|
|
206
|
+
store.setReplicaVisibilityObserver(view.flush);
|
|
207
|
+
store.setReplicaWriteBoundary(view.exclusive, view.snapshot);
|
|
208
|
+
// A local write journals under the retained declaration. When retained
|
|
209
|
+
// evidence for its key already names another declaration shape, or the stream is
|
|
210
|
+
// append-only and the content differs, the write completes a conflict. The
|
|
211
|
+
// merge must quarantine the key now, not at the next import. This is only a
|
|
212
|
+
// trigger: `reconcile` alone decides, so the check may over-approximate.
|
|
213
|
+
store.setReplicaLocalWriteObserver((instance, stream, recordKey) => {
|
|
214
|
+
const retainedForKey = db
|
|
215
|
+
.prepare("SELECT operation_json FROM pdpp_replica_view_operations WHERE instance=? AND stream=? AND record_key=? AND confirmed=1 AND kind IN ('upsert','delete')")
|
|
216
|
+
.all(instance, stream, recordKey).map((row) => JSON.parse(row.operation_json));
|
|
217
|
+
const shapes = new Set(retainedForKey.map((op) => declarationShape(op.declaration)));
|
|
218
|
+
const contents = new Set(retainedForKey.map((op) => canonicalJson(op.kind === "upsert" ? { data: op.data, blobs: op.blobs } : null)));
|
|
219
|
+
if (shapes.size > 1 ||
|
|
220
|
+
(contents.size > 1 &&
|
|
221
|
+
retainedForKey.some((op) => op.declaration.semantics === "append_only"))) {
|
|
222
|
+
reconciler.touchKey(instance, stream, recordKey);
|
|
223
|
+
reconciler.drain({ background: false });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
function includesPending(reader = "grant") {
|
|
227
|
+
return (pendingEnabled &&
|
|
228
|
+
replicaStateVisible("pending", reader, deps.visibilityPolicy));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Bind changes tokens to the integrity epoch of every requested connection,
|
|
232
|
+
* as page cursors are. A moved epoch (a quarantine, a terminal batch)
|
|
233
|
+
* expires both the page cursor and the session token, so a delta client
|
|
234
|
+
* resyncs instead of missing withdrawn rows. Without the store capability
|
|
235
|
+
* the store's reset fence alone applies. The field matches the one the
|
|
236
|
+
* synthesis lane's store binds into its own tokens.
|
|
237
|
+
*/
|
|
238
|
+
function bindChangesIntegrity(stream, reader, options, read) {
|
|
239
|
+
const epochOf = store.replicaIntegrityEpoch;
|
|
240
|
+
if (!epochOf)
|
|
241
|
+
return read();
|
|
242
|
+
const integrityValue = Object.fromEntries([...new Set(options.instanceIds)].sort().map((id) => [id, epochOf(id)]));
|
|
243
|
+
const integrity = JSON.stringify(integrityValue);
|
|
244
|
+
const visibilityEpoch = store.replicaVisibilityEpoch([...new Set(options.instanceIds)].sort());
|
|
245
|
+
for (const token of [options.cursor, options.changesSince]) {
|
|
246
|
+
if (!token)
|
|
247
|
+
continue;
|
|
248
|
+
let payload;
|
|
249
|
+
try {
|
|
250
|
+
payload = decodeCursor(token);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
continue; // The store refuses a malformed token as it does today.
|
|
254
|
+
}
|
|
255
|
+
if (payload.kind === "changes_since" &&
|
|
256
|
+
(JSON.stringify(payload.integrity ?? null) !== integrity ||
|
|
257
|
+
payload.visibilityEpoch !== visibilityEpoch))
|
|
258
|
+
throw new CursorExpiredError();
|
|
259
|
+
}
|
|
260
|
+
const bind = (token) => {
|
|
261
|
+
if (!token)
|
|
262
|
+
return token;
|
|
263
|
+
const payload = decodeCursor(token);
|
|
264
|
+
payload.integrity = JSON.parse(integrity);
|
|
265
|
+
payload.visibilityEpoch = visibilityEpoch;
|
|
266
|
+
return encodeCursor(payload);
|
|
267
|
+
};
|
|
268
|
+
const page = read();
|
|
269
|
+
return {
|
|
270
|
+
...page,
|
|
271
|
+
nextCursor: bind(page.nextCursor),
|
|
272
|
+
nextChangesSince: bind(page.nextChangesSince),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function readVisibleRecords(input) {
|
|
276
|
+
const rows = [];
|
|
277
|
+
let cursor;
|
|
278
|
+
do {
|
|
279
|
+
const page = api.readRecords({
|
|
280
|
+
reader: input.reader,
|
|
281
|
+
instanceIds: [input.connectionId],
|
|
282
|
+
stream: input.stream,
|
|
283
|
+
limit: 1000,
|
|
284
|
+
order: "asc",
|
|
285
|
+
cursor,
|
|
286
|
+
});
|
|
287
|
+
rows.push(...page.data);
|
|
288
|
+
cursor = page.nextCursor;
|
|
289
|
+
} while (cursor);
|
|
290
|
+
return rows;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* A stored copy, "absent", or "invalid": a published manifest whose size is
|
|
294
|
+
* not a safe non-negative integer. The manifest is checked on its own,
|
|
295
|
+
* because binaryLength reports a legacy row first and would mask it; a
|
|
296
|
+
* legacy row's size is length(bytes) and is always valid.
|
|
297
|
+
*/
|
|
298
|
+
function namespaceCopy(namespace, id) {
|
|
299
|
+
const manifest = db
|
|
300
|
+
.prepare("SELECT size_bytes AS size FROM pdpp_binary_manifests WHERE namespace=? AND blob_id=?")
|
|
301
|
+
.get(namespace, id);
|
|
302
|
+
if (manifest &&
|
|
303
|
+
!(typeof manifest.size === "number" &&
|
|
304
|
+
Number.isSafeInteger(manifest.size) &&
|
|
305
|
+
manifest.size >= 0))
|
|
306
|
+
return "invalid";
|
|
307
|
+
const size = binaryLength(db, namespace, id);
|
|
308
|
+
return size === undefined
|
|
309
|
+
? "absent"
|
|
310
|
+
: { size, chunks: () => readBinaryChunks(db, namespace, id) };
|
|
311
|
+
}
|
|
312
|
+
/** Custody of batches still pending: legacy rows, then chunks, by batch. */
|
|
313
|
+
function* pendingCopies(blobId) {
|
|
314
|
+
for (const row of db
|
|
315
|
+
.prepare("SELECT b.batch_id,length(b.bytes) AS size FROM pdpp_replica_pending_bytes b JOIN pdpp_replica_pending_batches p ON p.id=b.batch_id WHERE b.blob_id=? AND p.state='pending' ORDER BY b.batch_id")
|
|
316
|
+
.all(blobId))
|
|
317
|
+
yield {
|
|
318
|
+
size: row.size,
|
|
319
|
+
*chunks() {
|
|
320
|
+
yield db
|
|
321
|
+
.prepare("SELECT bytes FROM pdpp_replica_pending_bytes WHERE batch_id=? AND blob_id=?")
|
|
322
|
+
.get(row.batch_id, blobId).bytes;
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
for (const { id } of db
|
|
326
|
+
.prepare("SELECT id FROM pdpp_replica_pending_batches WHERE state='pending' ORDER BY id")
|
|
327
|
+
.all()) {
|
|
328
|
+
const copy = namespaceCopy("pending", pendingBlobKey(id, blobId));
|
|
329
|
+
if (typeof copy === "object")
|
|
330
|
+
yield copy;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Hashes a copy chunk by chunk; the whole value is never held. */
|
|
334
|
+
function copyVerifies(copy, digest, size) {
|
|
335
|
+
if (copy.size !== size)
|
|
336
|
+
return false;
|
|
337
|
+
const sha = createHash("sha256");
|
|
338
|
+
let length = 0;
|
|
339
|
+
try {
|
|
340
|
+
for (const bytes of copy.chunks()) {
|
|
341
|
+
length += bytes.length;
|
|
342
|
+
if (length > size)
|
|
343
|
+
return false;
|
|
344
|
+
sha.update(bytes);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
return length === size && `sha256:${sha.digest("hex")}` === digest;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The one verified copy a blob is served from, or undefined. It accepts what
|
|
354
|
+
* import and promotion accept: every retained copy (canonical, received)
|
|
355
|
+
* must verify, and the first of them serves. Without one, the first pending
|
|
356
|
+
* custody copy that verifies serves. Legacy rows and chunks follow one rule.
|
|
357
|
+
*/
|
|
358
|
+
function verifiedCopy(blobId, digest, size) {
|
|
359
|
+
if (blobId !== digest)
|
|
360
|
+
return undefined;
|
|
361
|
+
const retained = ["canonical", "received"]
|
|
362
|
+
.map((namespace) => namespaceCopy(namespace, blobId))
|
|
363
|
+
.filter((copy) => copy !== "absent");
|
|
364
|
+
if (retained.length)
|
|
365
|
+
return retained.every((copy) => copy !== "invalid" && copyVerifies(copy, digest, size))
|
|
366
|
+
? retained[0]
|
|
367
|
+
: undefined;
|
|
368
|
+
for (const copy of pendingCopies(blobId))
|
|
369
|
+
if (copyVerifies(copy, digest, size))
|
|
370
|
+
return copy;
|
|
371
|
+
return undefined;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Whole value for the byte route. Verification streams first, so a forged
|
|
375
|
+
* size or digest allocates nothing; callers read in one snapshot, so the
|
|
376
|
+
* assembled bytes are the verified ones.
|
|
377
|
+
*/
|
|
378
|
+
function verifiedVisibleBlob(id) {
|
|
379
|
+
const meta = store.getBlobMeta(id) ?? view.getBlobMeta(id);
|
|
380
|
+
const copy = meta && verifiedCopy(id, `sha256:${meta.sha256}`, meta.sizeBytes);
|
|
381
|
+
if (!copy)
|
|
382
|
+
return undefined;
|
|
383
|
+
const bytes = new Uint8Array(copy.size);
|
|
384
|
+
let offset = 0;
|
|
385
|
+
for (const chunk of copy.chunks()) {
|
|
386
|
+
bytes.set(chunk, offset);
|
|
387
|
+
offset += chunk.length;
|
|
388
|
+
}
|
|
389
|
+
return bytes;
|
|
390
|
+
}
|
|
391
|
+
function readSnapshot(read) {
|
|
392
|
+
return view.snapshot(read);
|
|
393
|
+
}
|
|
394
|
+
function readView(reader, cursor) {
|
|
395
|
+
return readSnapshot(() => {
|
|
396
|
+
const payload = cursor ? decodeCursor(cursor) : undefined;
|
|
397
|
+
const instanceIds = payload
|
|
398
|
+
? payload.kind === "list"
|
|
399
|
+
? (payload.instanceIds ?? [])
|
|
400
|
+
: Object.keys(payload.integrity ?? {})
|
|
401
|
+
: undefined;
|
|
402
|
+
const snapshot = {
|
|
403
|
+
visibilityEpoch: payload
|
|
404
|
+
? payload.kind === "list" && payload.viewRevision
|
|
405
|
+
? view.cursorVisibilityEpoch(payload.stream ?? "", instanceIds ?? [], reader, includesPending(reader))
|
|
406
|
+
: store.replicaVisibilityEpoch(instanceIds)
|
|
407
|
+
: view.visibilityEpoch(),
|
|
408
|
+
reader,
|
|
409
|
+
};
|
|
410
|
+
if (cursor && validateCursor(cursor, snapshot).kind === "expired")
|
|
411
|
+
throw new CursorExpiredError();
|
|
412
|
+
return snapshot;
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* The preview's blob readiness: the engine's `blobReady`, with bytes any
|
|
417
|
+
* retained or pending custody copy verifies, and stored metadata that does
|
|
418
|
+
* not contradict the descriptor.
|
|
419
|
+
*/
|
|
420
|
+
function previewBlobsValid(op, operations) {
|
|
421
|
+
const verified = new Set(op.blobs.flatMap((blob) => verifiedCopy(blob.blobId, blob.sha256, blob.sizeBytes)
|
|
422
|
+
? [blob.blobId]
|
|
423
|
+
: []));
|
|
424
|
+
return (blobReady(op, operations, verified) &&
|
|
425
|
+
op.blobs.every((blob) => {
|
|
426
|
+
const meta = store.getBlobMeta(blob.blobId);
|
|
427
|
+
return (!meta ||
|
|
428
|
+
(meta.sha256 === blob.sha256.slice(7) &&
|
|
429
|
+
meta.mimeType === blob.mimeType &&
|
|
430
|
+
meta.sizeBytes === blob.sizeBytes));
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Withdraw a connection from every reader: the quarantine fact (raises the
|
|
435
|
+
* integrity epoch), canonical tombstones, claims and completion. Caller
|
|
436
|
+
* holds the transaction and flushes the view.
|
|
437
|
+
*/
|
|
438
|
+
function withdrawConnection(instance) {
|
|
439
|
+
quarantineConnection(instance);
|
|
440
|
+
reconciler.touchConnection(instance);
|
|
441
|
+
for (const row of db
|
|
442
|
+
.prepare("SELECT stream,record_key FROM pdpp_records WHERE instance = ? AND deleted = 0")
|
|
443
|
+
.all(instance))
|
|
444
|
+
write(instance, row.stream, row.record_key, null);
|
|
445
|
+
db.prepare("DELETE FROM pdpp_blob_claims WHERE instance = ?").run(instance);
|
|
446
|
+
db.prepare("DELETE FROM pdpp_stream_completion WHERE instance = ?").run(instance);
|
|
447
|
+
}
|
|
448
|
+
/** Import a validated, owner-checked envelope. Caller holds the transaction. */
|
|
449
|
+
function importParsed(envelope, input) {
|
|
450
|
+
for (const op of envelope.operations)
|
|
451
|
+
validateMapping(op);
|
|
452
|
+
for (const blob of input.blobs ?? []) {
|
|
453
|
+
if (hash(blob.bytes) !== blob.blobId)
|
|
454
|
+
throw new Error("corrupt replica blob bytes");
|
|
455
|
+
clearMalformedManifest(db, "received", blob.blobId);
|
|
456
|
+
if (binaryLength(db, "received", blob.blobId) !== undefined) {
|
|
457
|
+
if (!verifyBinary(db, "received", blob.blobId, blob.blobId, blob.bytes.length))
|
|
458
|
+
throw new Error("corrupt retained replica bytes");
|
|
459
|
+
}
|
|
460
|
+
else if (blob.bytes.length <= 1024 * 1024) {
|
|
461
|
+
db.prepare("INSERT INTO pdpp_replica_received_bytes VALUES (?, ?)").run(blob.blobId, blob.bytes);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
const writer = binaryWriter(db, "received", blob.blobId);
|
|
465
|
+
writer.write(blob.bytes);
|
|
466
|
+
writer.finish();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
for (const blob of input.stagedBlobs ?? []) {
|
|
470
|
+
const from = blob.namespace ?? "staging";
|
|
471
|
+
if (!verifyBinary(db, from, blob.key, blob.blobId, blob.sizeBytes))
|
|
472
|
+
throw new Error("corrupt replica blob bytes");
|
|
473
|
+
clearMalformedManifest(db, "received", blob.blobId);
|
|
474
|
+
if (binaryLength(db, "received", blob.blobId) !== undefined) {
|
|
475
|
+
if (!verifyBinary(db, "received", blob.blobId, blob.blobId, blob.sizeBytes))
|
|
476
|
+
throw new Error("corrupt retained replica bytes");
|
|
477
|
+
}
|
|
478
|
+
else
|
|
479
|
+
copyBinary(db, from, blob.key, "received", blob.blobId);
|
|
480
|
+
}
|
|
481
|
+
for (const op of envelope.operations) {
|
|
482
|
+
db.prepare("INSERT OR IGNORE INTO pdpp_replica_received VALUES (?, ?)").run(canonicalJson(op), replicaOperationDigest(op));
|
|
483
|
+
db.prepare("UPDATE pdpp_replica_clock SET counter = MAX(counter, ?) WHERE id = 1").run(op.revision.counter);
|
|
484
|
+
}
|
|
485
|
+
// Only what this envelope can change is reconciled, duplicates included:
|
|
486
|
+
// the plan reports them again after a lost acknowledgement.
|
|
487
|
+
reconciler.touch(envelope.operations);
|
|
488
|
+
reconciler.touchBlobs([
|
|
489
|
+
...(input.blobs ?? []).map((blob) => blob.blobId),
|
|
490
|
+
...(input.stagedBlobs ?? []).map((blob) => blob.blobId),
|
|
491
|
+
]);
|
|
492
|
+
const { plan } = reconciler.drain({ background: false });
|
|
493
|
+
view.flush();
|
|
494
|
+
return plan;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Batch copies (contract v3.1). The legacy pending row of an id mirrors its
|
|
498
|
+
* single `pending` copy, so #438's preview view and its triggers keep one
|
|
499
|
+
* key per id. Pending bytes stay keyed (id, blobId): a blob id is its
|
|
500
|
+
* content hash, so copies that share a blob share identical bytes, and a
|
|
501
|
+
* blob is released only when no other retained copy of the id names it.
|
|
502
|
+
*/
|
|
503
|
+
const copies = createReplicaCopies({
|
|
504
|
+
db,
|
|
505
|
+
owner,
|
|
506
|
+
importEnvelope(envelope, blobs, id) {
|
|
507
|
+
importParsed(envelope, {
|
|
508
|
+
stagedBlobs: blobs.map((b) => ({
|
|
509
|
+
blobId: b.blobId,
|
|
510
|
+
key: pendingBlobKey(id, b.blobId),
|
|
511
|
+
sizeBytes: b.size,
|
|
512
|
+
namespace: "pending",
|
|
513
|
+
})),
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
// Contract A: withdrawal through the one eligibility predicate only.
|
|
517
|
+
// No canonical tombstones: they would destroy the rows a lifted
|
|
518
|
+
// quarantine, or a conflicted owner's local projection, still needs.
|
|
519
|
+
quarantineConnection(instance) {
|
|
520
|
+
if (quarantineConnection(instance))
|
|
521
|
+
reconciler.touchConnection(instance);
|
|
522
|
+
},
|
|
523
|
+
preview: {
|
|
524
|
+
show(id, envelope, blobsJson) {
|
|
525
|
+
const encoded = canonicalJson(envelope);
|
|
526
|
+
const row = db
|
|
527
|
+
.prepare("SELECT state,envelope_json,blobs_json FROM pdpp_replica_pending_batches WHERE id=?")
|
|
528
|
+
.get(id);
|
|
529
|
+
if (row?.state === "pending" &&
|
|
530
|
+
row.envelope_json === encoded &&
|
|
531
|
+
row.blobs_json === blobsJson)
|
|
532
|
+
return;
|
|
533
|
+
if (row?.state === "pending")
|
|
534
|
+
view.removePending(id);
|
|
535
|
+
db.prepare("INSERT INTO pdpp_replica_pending_batches VALUES (?,?,?,'pending',NULL) ON CONFLICT(id) DO UPDATE SET envelope_json=excluded.envelope_json,blobs_json=excluded.blobs_json,state='pending',reason=NULL").run(id, encoded, blobsJson);
|
|
536
|
+
view.addPending(id, envelope.operations);
|
|
537
|
+
},
|
|
538
|
+
hide(id, state) {
|
|
539
|
+
if (db
|
|
540
|
+
.prepare("UPDATE pdpp_replica_pending_batches SET state=?,reason=? WHERE id=? AND state='pending'")
|
|
541
|
+
.run(state, state === "quarantined" ? "copy conflict" : null, id)
|
|
542
|
+
.changes > 0)
|
|
543
|
+
view.removePending(id);
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
releaseBytes(id, blobIds) {
|
|
547
|
+
for (const blobId of blobIds)
|
|
548
|
+
deleteBinary(db, "pending", pendingBlobKey(id, blobId));
|
|
549
|
+
},
|
|
550
|
+
now: () => Date.now(),
|
|
551
|
+
});
|
|
552
|
+
const hasCopies = (id) => db
|
|
553
|
+
.prepare("SELECT 1 FROM pdpp_replica_copies WHERE id=? LIMIT 1")
|
|
554
|
+
.get(id) !== undefined;
|
|
555
|
+
/** Copy-path mutation: authenticated, one transaction, view flushed inside it. */
|
|
556
|
+
function copyTransaction(credential, effect) {
|
|
557
|
+
authorize(credential);
|
|
558
|
+
return view.exclusive(() => {
|
|
559
|
+
const result = effect();
|
|
560
|
+
view.flush();
|
|
561
|
+
return result;
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const api = {
|
|
565
|
+
setPendingEnabled(enabled) {
|
|
566
|
+
pendingEnabled = enabled === true;
|
|
567
|
+
},
|
|
568
|
+
/**
|
|
569
|
+
* Batch-copy custody and promotion (contract v3.1). Trusted host hooks:
|
|
570
|
+
* the caller verified every fragment's bytes against its own hash pair.
|
|
571
|
+
*/
|
|
572
|
+
copies: {
|
|
573
|
+
stage(input) {
|
|
574
|
+
authorize(input.credential);
|
|
575
|
+
const envelope = replicaEnvelopeSchema.parse(input.copy.envelope);
|
|
576
|
+
if (envelope.owner !== owner ||
|
|
577
|
+
envelope.operations.some((op) => op.owner !== owner))
|
|
578
|
+
throw new Error("wrong replica owner");
|
|
579
|
+
for (const op of envelope.operations)
|
|
580
|
+
validateMapping(op);
|
|
581
|
+
for (const blob of input.blobs ?? [])
|
|
582
|
+
if (hash(blob.bytes) !== blob.blobId)
|
|
583
|
+
throw new Error("corrupt replica blob bytes");
|
|
584
|
+
for (const blob of input.stagedBlobs ?? [])
|
|
585
|
+
if (!verifyBinary(db, "staging", blob.key, blob.blobId, blob.sizeBytes))
|
|
586
|
+
throw new Error("corrupt replica blob bytes");
|
|
587
|
+
const id = input.copy.id;
|
|
588
|
+
return copyTransaction(input.credential, () => {
|
|
589
|
+
const held = (blobId, size) => verifyBinary(db, "pending", pendingBlobKey(id, blobId), blobId, size);
|
|
590
|
+
for (const blob of input.blobs ?? [])
|
|
591
|
+
if (!held(blob.blobId, blob.bytes.length)) {
|
|
592
|
+
const writer = binaryWriter(db, "pending", pendingBlobKey(id, blob.blobId));
|
|
593
|
+
writer.write(blob.bytes);
|
|
594
|
+
writer.finish();
|
|
595
|
+
}
|
|
596
|
+
for (const blob of input.stagedBlobs ?? [])
|
|
597
|
+
if (!held(blob.blobId, blob.sizeBytes))
|
|
598
|
+
copyBinary(db, "staging", blob.key, "pending", pendingBlobKey(id, blob.blobId));
|
|
599
|
+
return copies.stage({
|
|
600
|
+
...input.copy,
|
|
601
|
+
envelope,
|
|
602
|
+
blobs: [
|
|
603
|
+
...(input.blobs ?? []).map((b) => ({
|
|
604
|
+
blobId: b.blobId,
|
|
605
|
+
size: b.bytes.length,
|
|
606
|
+
})),
|
|
607
|
+
...(input.stagedBlobs ?? []).map((b) => ({
|
|
608
|
+
blobId: b.blobId,
|
|
609
|
+
size: b.sizeBytes,
|
|
610
|
+
})),
|
|
611
|
+
],
|
|
612
|
+
}, input.refetch ?? { attempts: 0, budget: REPLICA_REFETCH_BUDGET });
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
/** Append one verified finalized fact (F2). Returns true when new. */
|
|
616
|
+
recordFact(input) {
|
|
617
|
+
return copyTransaction(input.credential, () => copies.recordFact(input));
|
|
618
|
+
},
|
|
619
|
+
decide: copies.decide,
|
|
620
|
+
disposition: copies.disposition,
|
|
621
|
+
list: copies.copies,
|
|
622
|
+
apply(input) {
|
|
623
|
+
copyTransaction(input.credential, () => copies.apply(input.id, input.decision));
|
|
624
|
+
},
|
|
625
|
+
promote(input) {
|
|
626
|
+
return copyTransaction(input.credential, () => copies.promote({ id: input.id, copyKey: input.copyKey }));
|
|
627
|
+
},
|
|
628
|
+
terminate(input) {
|
|
629
|
+
copyTransaction(input.credential, () => copies.terminate(input.id, input.reason));
|
|
630
|
+
},
|
|
631
|
+
},
|
|
632
|
+
stage(input) {
|
|
633
|
+
authorize(input.credential);
|
|
634
|
+
// A batch with retained copies changes only through the copy path.
|
|
635
|
+
if (hasCopies(input.id))
|
|
636
|
+
throw new ReplicaCopyRefused("copy_path_required");
|
|
637
|
+
if (!pendingEnabled)
|
|
638
|
+
return;
|
|
639
|
+
const envelope = replicaEnvelopeSchema.parse(input.envelope);
|
|
640
|
+
if (envelope.owner !== owner ||
|
|
641
|
+
envelope.operations.some((op) => op.owner !== owner))
|
|
642
|
+
throw new Error("wrong replica owner");
|
|
643
|
+
for (const op of envelope.operations)
|
|
644
|
+
validateMapping(op);
|
|
645
|
+
for (const blob of input.blobs ?? [])
|
|
646
|
+
if (hash(blob.bytes) !== blob.blobId)
|
|
647
|
+
throw new Error("corrupt replica blob bytes");
|
|
648
|
+
const encoded = canonicalJson(envelope);
|
|
649
|
+
for (const blob of input.stagedBlobs ?? [])
|
|
650
|
+
if (!verifyBinary(db, "staging", blob.key, blob.blobId, blob.sizeBytes))
|
|
651
|
+
throw new Error("corrupt replica blob bytes");
|
|
652
|
+
const blobs = canonicalJson([
|
|
653
|
+
...(input.blobs ?? []).map((b) => ({
|
|
654
|
+
blobId: b.blobId,
|
|
655
|
+
size: b.bytes.length,
|
|
656
|
+
})),
|
|
657
|
+
...(input.stagedBlobs ?? []).map((b) => ({
|
|
658
|
+
blobId: b.blobId,
|
|
659
|
+
size: b.sizeBytes,
|
|
660
|
+
})),
|
|
661
|
+
]);
|
|
662
|
+
view.exclusive(() => {
|
|
663
|
+
const previous = db
|
|
664
|
+
.prepare("SELECT * FROM pdpp_replica_pending_batches WHERE id=?")
|
|
665
|
+
.get(input.id);
|
|
666
|
+
if (previous?.state === "confirmed") {
|
|
667
|
+
if (previous.envelope_json !== encoded ||
|
|
668
|
+
previous.blobs_json !== blobs)
|
|
669
|
+
throw new Error("confirmed replica batch is immutable");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (previous &&
|
|
673
|
+
(previous.envelope_json !== encoded || previous.blobs_json !== blobs)) {
|
|
674
|
+
db.prepare("UPDATE pdpp_replica_pending_batches SET state='quarantined',reason='batch equivocation' WHERE id=?").run(input.id);
|
|
675
|
+
view.removePending(input.id);
|
|
676
|
+
view.flush();
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
db.prepare("INSERT OR IGNORE INTO pdpp_replica_pending_batches VALUES (?,?,?,'pending',NULL)").run(input.id, encoded, blobs);
|
|
680
|
+
if (!previous) {
|
|
681
|
+
for (const blob of input.blobs ?? [])
|
|
682
|
+
db.prepare("INSERT INTO pdpp_replica_pending_bytes VALUES (?,?,?)").run(input.id, blob.blobId, blob.bytes);
|
|
683
|
+
for (const blob of input.stagedBlobs ?? [])
|
|
684
|
+
copyBinary(db, "staging", blob.key, "pending", pendingBlobKey(input.id, blob.blobId));
|
|
685
|
+
}
|
|
686
|
+
if (!previous)
|
|
687
|
+
view.addPending(input.id, envelope.operations);
|
|
688
|
+
view.flush();
|
|
689
|
+
});
|
|
690
|
+
},
|
|
691
|
+
/** Durable state of a staged batch, or undefined when never staged. */
|
|
692
|
+
pendingState(id) {
|
|
693
|
+
const row = db
|
|
694
|
+
.prepare("SELECT state FROM pdpp_replica_pending_batches WHERE id=?")
|
|
695
|
+
.get(id);
|
|
696
|
+
return row?.state;
|
|
697
|
+
},
|
|
698
|
+
quarantine(input) {
|
|
699
|
+
authorize(input.credential);
|
|
700
|
+
// A copy-backed batch fails closed through the terminal transition.
|
|
701
|
+
if (hasCopies(input.id))
|
|
702
|
+
return api.copies.terminate({ ...input, reason: "finalized_conflict" });
|
|
703
|
+
view.exclusive(() => {
|
|
704
|
+
db.prepare("UPDATE pdpp_replica_pending_batches SET state='quarantined',reason=? WHERE id=? AND state='pending'").run(input.reason, input.id);
|
|
705
|
+
view.removePending(input.id);
|
|
706
|
+
view.flush();
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
/** Trusted host hook: caller must verify every fragment against finalized #433 evidence first. */
|
|
710
|
+
promote(input) {
|
|
711
|
+
authorize(input.credential);
|
|
712
|
+
// Bare promote of a copy-backed batch: refused unless exactly one copy.
|
|
713
|
+
if (hasCopies(input.id)) {
|
|
714
|
+
const result = api.copies.promote(input);
|
|
715
|
+
if (result.kind !== "promoted")
|
|
716
|
+
throw new ReplicaCopyRefused(result.kind === "terminal" ? result.reason : "wait_predecessor");
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
return view.exclusive(() => {
|
|
720
|
+
const row = db
|
|
721
|
+
.prepare("SELECT * FROM pdpp_replica_pending_batches WHERE id=?")
|
|
722
|
+
.get(input.id);
|
|
723
|
+
if (!row)
|
|
724
|
+
throw new Error("pending replica batch not found");
|
|
725
|
+
if (row.state === "quarantined")
|
|
726
|
+
throw new Error("pending replica batch quarantined");
|
|
727
|
+
if (row.state === "confirmed")
|
|
728
|
+
return;
|
|
729
|
+
const legacy = db
|
|
730
|
+
.prepare("SELECT blob_id,bytes FROM pdpp_replica_pending_bytes WHERE batch_id=?")
|
|
731
|
+
.all(input.id).map((b) => ({ blobId: b.blob_id, bytes: b.bytes }));
|
|
732
|
+
const chunked = JSON.parse(row.blobs_json).filter((b) => !legacy.some((l) => l.blobId === b.blobId) &&
|
|
733
|
+
binaryLength(db, "pending", pendingBlobKey(input.id, b.blobId)) !==
|
|
734
|
+
undefined);
|
|
735
|
+
const result = api.import({
|
|
736
|
+
credential: input.credential,
|
|
737
|
+
envelope: JSON.parse(row.envelope_json),
|
|
738
|
+
blobs: legacy,
|
|
739
|
+
stagedBlobs: chunked.map((b) => ({
|
|
740
|
+
blobId: b.blobId,
|
|
741
|
+
key: pendingBlobKey(input.id, b.blobId),
|
|
742
|
+
sizeBytes: b.size,
|
|
743
|
+
namespace: "pending",
|
|
744
|
+
})),
|
|
745
|
+
});
|
|
746
|
+
db.prepare("UPDATE pdpp_replica_pending_batches SET state='confirmed' WHERE id=?").run(input.id);
|
|
747
|
+
db.prepare("DELETE FROM pdpp_replica_pending_bytes WHERE batch_id=?").run(input.id);
|
|
748
|
+
for (const b of chunked)
|
|
749
|
+
deleteBinary(db, "pending", pendingBlobKey(input.id, b.blobId));
|
|
750
|
+
view.removePending(input.id);
|
|
751
|
+
view.flush();
|
|
752
|
+
return result;
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
connectionVisibility: view.connectionVisibility,
|
|
756
|
+
readVisibleRecords,
|
|
757
|
+
readView,
|
|
758
|
+
readRecords(input) {
|
|
759
|
+
return readSnapshot(() => {
|
|
760
|
+
if (input.cursor)
|
|
761
|
+
readView(input.reader ?? "grant", input.cursor);
|
|
762
|
+
if (!pendingEnabled &&
|
|
763
|
+
!input.instanceIds.some((id) => view.connectionVisibility(id))) {
|
|
764
|
+
// A view cursor never continues on canonical pages: no mixed snapshots.
|
|
765
|
+
const cursor = input.cursor && decodeCursor(input.cursor);
|
|
766
|
+
if (cursor && cursor.kind === "list" && cursor.viewRevision)
|
|
767
|
+
throw new CursorExpiredError();
|
|
768
|
+
return store.listRecords(input.stream, input);
|
|
769
|
+
}
|
|
770
|
+
return view.listRecords(input.stream, input, input.reader ?? "grant", includesPending(input.reader));
|
|
771
|
+
});
|
|
772
|
+
},
|
|
773
|
+
readStore(input = {}) {
|
|
774
|
+
const reader = input.reader ?? "grant";
|
|
775
|
+
const hasPreview = () => includesPending(reader);
|
|
776
|
+
const needsReferenceGate = () => hasPreview() || view.hasBindingConflictFacts;
|
|
777
|
+
const refs = (id) => readSnapshot(() => {
|
|
778
|
+
// The enclosing snapshot carries point reads across projection commits.
|
|
779
|
+
return needsReferenceGate()
|
|
780
|
+
? view.findBlobReferences(id, reader, hasPreview())
|
|
781
|
+
: store.findBlobReferences(id);
|
|
782
|
+
});
|
|
783
|
+
const canReadBlob = (id) => view.hasBlobReference(id, reader, hasPreview());
|
|
784
|
+
return {
|
|
785
|
+
connectionVisibility: view.connectionVisibility,
|
|
786
|
+
withReadSnapshot: readSnapshot,
|
|
787
|
+
getRecord: (instance, stream, key) => readSnapshot(() => {
|
|
788
|
+
// The enclosing snapshot carries point reads atomically.
|
|
789
|
+
return hasPreview() || view.connectionVisibility(instance)
|
|
790
|
+
? view.getRecord(instance, stream, key, reader, hasPreview())
|
|
791
|
+
: store.getRecord(instance, stream, key);
|
|
792
|
+
}),
|
|
793
|
+
listRecords: (stream, options) => api.readRecords({ ...options, stream, reader }),
|
|
794
|
+
listStreams: (ids) => readSnapshot(() => {
|
|
795
|
+
return pendingEnabled ||
|
|
796
|
+
ids.some((id) => view.connectionVisibility(id))
|
|
797
|
+
? view.listStreams(ids, hasPreview(), reader)
|
|
798
|
+
: store.listStreams(ids);
|
|
799
|
+
}),
|
|
800
|
+
// Changes sessions allocate a canonical horizon. Reserve the writer before
|
|
801
|
+
// selecting policy so that the horizon and conflict fence cannot race.
|
|
802
|
+
changesSince: (stream, options) => view.exclusive(() => {
|
|
803
|
+
readView(reader, options.cursor ?? options.changesSince);
|
|
804
|
+
const visible = options.instanceIds.filter((instance) => {
|
|
805
|
+
const visibility = view.connectionVisibility(instance);
|
|
806
|
+
return (!visibility ||
|
|
807
|
+
replicaStateVisible(visibility.state, reader, deps.visibilityPolicy, "received"));
|
|
808
|
+
});
|
|
809
|
+
return bindChangesIntegrity(stream, reader, options, () => store.changesSinceForInstances(stream, options, visible));
|
|
810
|
+
}),
|
|
811
|
+
getBlobBytes: (id) => readSnapshot(() => {
|
|
812
|
+
// The enclosing snapshot carries blob-byte reads atomically.
|
|
813
|
+
return !needsReferenceGate()
|
|
814
|
+
? store.getBlobBytes(id)
|
|
815
|
+
: canReadBlob(id)
|
|
816
|
+
? verifiedVisibleBlob(id)
|
|
817
|
+
: undefined;
|
|
818
|
+
}),
|
|
819
|
+
getBlobMeta: (id) => readSnapshot(() => {
|
|
820
|
+
// The enclosing snapshot carries blob-metadata reads atomically.
|
|
821
|
+
return !needsReferenceGate()
|
|
822
|
+
? store.getBlobMeta(id)
|
|
823
|
+
: canReadBlob(id)
|
|
824
|
+
? (store.getBlobMeta(id) ?? view.getBlobMeta(id))
|
|
825
|
+
: undefined;
|
|
826
|
+
}),
|
|
827
|
+
findBlobReferences: refs,
|
|
828
|
+
// Receipts describe canonical rows as stored: a preview or a hidden or
|
|
829
|
+
// local-only connection shows other rows, so it gets none.
|
|
830
|
+
listStreamCompletions: (instance) => readSnapshot(() => {
|
|
831
|
+
// The enclosing snapshot carries completion reads atomically.
|
|
832
|
+
return hasPreview() || view.connectionVisibility(instance)
|
|
833
|
+
? []
|
|
834
|
+
: store.listStreamCompletions(instance);
|
|
835
|
+
}),
|
|
836
|
+
};
|
|
837
|
+
},
|
|
838
|
+
/** Called only after the transport proves a finalized immutable-slot conflict.
|
|
839
|
+
* Keep evidence, but permanently retract the authority derived from its batch.
|
|
840
|
+
*/
|
|
841
|
+
quarantineImported(input) {
|
|
842
|
+
authorize(input.credential);
|
|
843
|
+
const envelope = replicaEnvelopeSchema.parse(input.envelope);
|
|
844
|
+
if (envelope.owner !== owner ||
|
|
845
|
+
envelope.operations.some((op) => op.owner !== owner))
|
|
846
|
+
throw new Error("wrong replica owner");
|
|
847
|
+
view.exclusive(() => {
|
|
848
|
+
for (const op of envelope.operations)
|
|
849
|
+
validateMapping(op);
|
|
850
|
+
const records = envelope.operations.filter((op) => op.kind === "upsert" || op.kind === "delete");
|
|
851
|
+
const connections = new Set(envelope.operations
|
|
852
|
+
.filter((op) => op.kind !== "upsert" &&
|
|
853
|
+
op.kind !== "delete" &&
|
|
854
|
+
(op.kind !== "blob-claim" ||
|
|
855
|
+
!records.some((record) => record.kind === "upsert" &&
|
|
856
|
+
record.connectionId === op.connectionId &&
|
|
857
|
+
record.generation === op.generation &&
|
|
858
|
+
record.blobs.some((blob) => canonicalJson(blob) === canonicalJson(op.blob)))))
|
|
859
|
+
.map((op) => op.connectionId));
|
|
860
|
+
for (const instance of connections)
|
|
861
|
+
withdrawConnection(instance);
|
|
862
|
+
for (const op of records) {
|
|
863
|
+
quarantineKey(op.connectionId, op.generation, op.declaration.stream, op.recordKey);
|
|
864
|
+
if (binding(op.connectionId)?.generation === op.generation)
|
|
865
|
+
write(op.connectionId, op.declaration.stream, op.recordKey, null);
|
|
866
|
+
}
|
|
867
|
+
// Incremental: each new quarantine fact raised its connection's
|
|
868
|
+
// integrity epoch in this transaction, so saved cursors expire, and
|
|
869
|
+
// reads apply the quarantine predicate. The record withdrawals above
|
|
870
|
+
// marked their keys dirty; flush re-derives only those.
|
|
871
|
+
view.flush();
|
|
872
|
+
});
|
|
873
|
+
},
|
|
874
|
+
import(input) {
|
|
875
|
+
authorize(input.credential);
|
|
876
|
+
const envelope = replicaEnvelopeSchema.parse(input.envelope);
|
|
877
|
+
if (envelope.owner !== owner ||
|
|
878
|
+
envelope.operations.some((op) => op.owner !== owner))
|
|
879
|
+
throw new Error("wrong replica owner");
|
|
880
|
+
return view.exclusive(() => importParsed(envelope, input));
|
|
881
|
+
},
|
|
882
|
+
/** Trusted host notification after declaration documents or validators change. */
|
|
883
|
+
reconcileDeclarations(input) {
|
|
884
|
+
authorize(input.credential);
|
|
885
|
+
return view.exclusive(() => {
|
|
886
|
+
reconciler.beginPass();
|
|
887
|
+
const { plan } = reconciler.drain({ background: true });
|
|
888
|
+
view.reconcileAll();
|
|
889
|
+
return plan;
|
|
890
|
+
});
|
|
891
|
+
},
|
|
892
|
+
/**
|
|
893
|
+
* Runs `run` in one write transaction whose derived state is current.
|
|
894
|
+
* Hosts that combine receiver calls with their own writes enter here, so
|
|
895
|
+
* the nested receiver calls can trust the transaction's view.
|
|
896
|
+
*/
|
|
897
|
+
atomically(run) {
|
|
898
|
+
return view.exclusive(run);
|
|
899
|
+
},
|
|
900
|
+
/** A full reconcile pass over retained history, finished in this call. */
|
|
901
|
+
retry(input) {
|
|
902
|
+
authorize(input.credential);
|
|
903
|
+
return view.exclusive(() => {
|
|
904
|
+
reconciler.beginPass();
|
|
905
|
+
const { plan } = reconciler.drain({ background: true });
|
|
906
|
+
view.flush();
|
|
907
|
+
return plan;
|
|
908
|
+
});
|
|
909
|
+
},
|
|
910
|
+
/**
|
|
911
|
+
* One bounded page of reconcile work. `restart` first queues a full pass,
|
|
912
|
+
* as `retry` does; later pages continue it. `complete` is true when no
|
|
913
|
+
* work is queued. The plan lists only this page's issues.
|
|
914
|
+
*/
|
|
915
|
+
reconcileStep(input) {
|
|
916
|
+
authorize(input.credential);
|
|
917
|
+
return view.exclusive(() => {
|
|
918
|
+
if (input.restart)
|
|
919
|
+
reconciler.beginPass();
|
|
920
|
+
const { plan, complete } = reconciler.drain({
|
|
921
|
+
background: true,
|
|
922
|
+
page: input.page,
|
|
923
|
+
});
|
|
924
|
+
view.flush();
|
|
925
|
+
return { plan, complete };
|
|
926
|
+
});
|
|
927
|
+
},
|
|
928
|
+
/** Whether reconcile work is queued. Reads no retained history. */
|
|
929
|
+
reconcileQueued: () => reconciler.queued(),
|
|
930
|
+
/** Retained evidence reconcile has loaded, cumulatively. For bounds tests. */
|
|
931
|
+
reconcileStats: () => reconciler.stats(),
|
|
932
|
+
/**
|
|
933
|
+
* Reconcile, then return the operations that explain canonical state: the
|
|
934
|
+
* merge's winner for each shown row and tombstone, and each available
|
|
935
|
+
* completion. A new writer's rebaseline copies order and binding only
|
|
936
|
+
* from these (host contract §4.5).
|
|
937
|
+
*/
|
|
938
|
+
explainState(input) {
|
|
939
|
+
authorize(input.credential);
|
|
940
|
+
return view.exclusive(() => {
|
|
941
|
+
reconciler.beginPass();
|
|
942
|
+
const { explaining } = reconciler.drain({ background: true });
|
|
943
|
+
view.flush();
|
|
944
|
+
return explaining;
|
|
945
|
+
});
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
return api;
|
|
949
|
+
}
|
|
950
|
+
//# sourceMappingURL=pdpp-replication-receiver.js.map
|