@opendatalabs/personal-server-ts-server 1.33.8 → 1.34.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/__fixtures__/bound-record-store.d.ts +19 -0
- package/dist/__fixtures__/bound-record-store.d.ts.map +1 -0
- package/dist/__fixtures__/bound-record-store.js +60 -0
- package/dist/__fixtures__/bound-record-store.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts +41 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js +34 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts +112 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js +226 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts +135 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts.map +1 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js +21 -0
- package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js.map +1 -0
- package/dist/api-auth.d.ts +18 -2
- package/dist/api-auth.d.ts.map +1 -1
- package/dist/api-auth.js +114 -22
- package/dist/api-auth.js.map +1 -1
- package/dist/app.d.ts +60 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +139 -1
- package/dist/app.js.map +1 -1
- package/dist/bootstrap.d.ts +10 -0
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +359 -8
- package/dist/bootstrap.js.map +1 -1
- package/dist/enclave-main.d.ts +4 -0
- package/dist/enclave-main.d.ts.map +1 -1
- package/dist/enclave-main.js +152 -1
- package/dist/enclave-main.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +1 -1
- package/dist/middleware/body-limit.d.ts.map +1 -1
- package/dist/middleware/body-limit.js +8 -3
- package/dist/middleware/body-limit.js.map +1 -1
- package/dist/middleware/cors.d.ts.map +1 -1
- package/dist/middleware/cors.js +1 -0
- package/dist/middleware/cors.js.map +1 -1
- package/dist/package.json +9 -1
- package/dist/pdpp/bootstrap.d.ts +72 -0
- package/dist/pdpp/bootstrap.d.ts.map +1 -0
- package/dist/pdpp/bootstrap.js +270 -0
- package/dist/pdpp/bootstrap.js.map +1 -0
- package/dist/pdpp/chain-enforcement.d.ts +95 -0
- package/dist/pdpp/chain-enforcement.d.ts.map +1 -0
- package/dist/pdpp/chain-enforcement.js +145 -0
- package/dist/pdpp/chain-enforcement.js.map +1 -0
- package/dist/pdpp/client-document-fetch.d.ts +63 -0
- package/dist/pdpp/client-document-fetch.d.ts.map +1 -0
- package/dist/pdpp/client-document-fetch.js +254 -0
- package/dist/pdpp/client-document-fetch.js.map +1 -0
- package/dist/pdpp/declaration-registry.d.ts +83 -0
- package/dist/pdpp/declaration-registry.d.ts.map +1 -0
- package/dist/pdpp/declaration-registry.js +251 -0
- package/dist/pdpp/declaration-registry.js.map +1 -0
- package/dist/pdpp/deployment.d.ts +70 -0
- package/dist/pdpp/deployment.d.ts.map +1 -0
- package/dist/pdpp/deployment.js +104 -0
- package/dist/pdpp/deployment.js.map +1 -0
- package/dist/pdpp/record-schema.d.ts +2 -0
- package/dist/pdpp/record-schema.d.ts.map +1 -0
- package/dist/pdpp/record-schema.js +2 -0
- package/dist/pdpp/record-schema.js.map +1 -0
- package/dist/pdpp/records-bootstrap.d.ts +111 -0
- package/dist/pdpp/records-bootstrap.d.ts.map +1 -0
- package/dist/pdpp/records-bootstrap.js +353 -0
- package/dist/pdpp/records-bootstrap.js.map +1 -0
- package/dist/pdpp/source-id-compat.d.ts +5 -0
- package/dist/pdpp/source-id-compat.d.ts.map +1 -0
- package/dist/pdpp/source-id-compat.js +16 -0
- package/dist/pdpp/source-id-compat.js.map +1 -0
- package/dist/pdpp/test-connections.d.ts +6 -0
- package/dist/pdpp/test-connections.d.ts.map +1 -0
- package/dist/pdpp/test-connections.js +37 -0
- package/dist/pdpp/test-connections.js.map +1 -0
- package/dist/routes/auth-device.d.ts.map +1 -1
- package/dist/routes/auth-device.js +3 -0
- package/dist/routes/auth-device.js.map +1 -1
- package/dist/routes/chunked-scope-import.d.ts +41 -0
- package/dist/routes/chunked-scope-import.d.ts.map +1 -0
- package/dist/routes/chunked-scope-import.js +901 -0
- package/dist/routes/chunked-scope-import.js.map +1 -0
- package/dist/routes/data.d.ts +23 -1
- package/dist/routes/data.d.ts.map +1 -1
- package/dist/routes/data.js +112 -1
- package/dist/routes/data.js.map +1 -1
- package/dist/routes/grants.d.ts.map +1 -1
- package/dist/routes/grants.js +2 -0
- package/dist/routes/grants.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +3 -0
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/pdpp-auth.d.ts +157 -0
- package/dist/routes/pdpp-auth.d.ts.map +1 -0
- package/dist/routes/pdpp-auth.js +953 -0
- package/dist/routes/pdpp-auth.js.map +1 -0
- package/dist/routes/pdpp-blobs.d.ts +26 -0
- package/dist/routes/pdpp-blobs.d.ts.map +1 -0
- package/dist/routes/pdpp-blobs.js +179 -0
- package/dist/routes/pdpp-blobs.js.map +1 -0
- package/dist/routes/pdpp-connections.d.ts +28 -0
- package/dist/routes/pdpp-connections.d.ts.map +1 -0
- package/dist/routes/pdpp-connections.js +144 -0
- package/dist/routes/pdpp-connections.js.map +1 -0
- package/dist/routes/pdpp-declarations.d.ts +58 -0
- package/dist/routes/pdpp-declarations.d.ts.map +1 -0
- package/dist/routes/pdpp-declarations.js +94 -0
- package/dist/routes/pdpp-declarations.js.map +1 -0
- package/dist/routes/pdpp-instance-bindings.d.ts +26 -0
- package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -0
- package/dist/routes/pdpp-instance-bindings.js +124 -0
- package/dist/routes/pdpp-instance-bindings.js.map +1 -0
- package/dist/routes/pdpp-records.d.ts +158 -0
- package/dist/routes/pdpp-records.d.ts.map +1 -0
- package/dist/routes/pdpp-records.js +1537 -0
- package/dist/routes/pdpp-records.js.map +1 -0
- package/dist/routes/pdpp-well-known.d.ts +13 -0
- package/dist/routes/pdpp-well-known.d.ts.map +1 -0
- package/dist/routes/pdpp-well-known.js +20 -0
- package/dist/routes/pdpp-well-known.js.map +1 -0
- package/dist/storage/declaration-witness.d.ts +56 -0
- package/dist/storage/declaration-witness.d.ts.map +1 -0
- package/dist/storage/declaration-witness.js +273 -0
- package/dist/storage/declaration-witness.js.map +1 -0
- package/dist/storage/hierarchy.d.ts +25 -1
- package/dist/storage/hierarchy.d.ts.map +1 -1
- package/dist/storage/hierarchy.js +210 -16
- package/dist/storage/hierarchy.js.map +1 -1
- package/dist/storage/import-operations.d.ts +11 -0
- package/dist/storage/import-operations.d.ts.map +1 -0
- package/dist/storage/import-operations.js +261 -0
- package/dist/storage/import-operations.js.map +1 -0
- package/dist/storage/index-manager.d.ts +24 -2
- package/dist/storage/index-manager.d.ts.map +1 -1
- package/dist/storage/index-manager.js +279 -30
- package/dist/storage/index-manager.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 +189 -8
- package/dist/storage/index-schema.js.map +1 -1
- package/dist/storage/legacy-scope-authority.d.ts +14 -0
- package/dist/storage/legacy-scope-authority.d.ts.map +1 -0
- package/dist/storage/legacy-scope-authority.js +298 -0
- package/dist/storage/legacy-scope-authority.js.map +1 -0
- package/dist/storage/node-data-storage.d.ts +6 -0
- package/dist/storage/node-data-storage.d.ts.map +1 -1
- package/dist/storage/node-data-storage.js +161 -2
- package/dist/storage/node-data-storage.js.map +1 -1
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts +17 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js +159 -0
- package/dist/storage/pdpp-grant-binding-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts +112 -0
- package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -0
- package/dist/storage/pdpp-records-sqlite-store.js +1440 -0
- package/dist/storage/pdpp-records-sqlite-store.js.map +1 -0
- package/dist/storage/pdpp-stream-completion.d.ts +5 -0
- package/dist/storage/pdpp-stream-completion.d.ts.map +1 -0
- package/dist/storage/pdpp-stream-completion.js +10 -0
- package/dist/storage/pdpp-stream-completion.js.map +1 -0
- package/dist/storage/scope-deletion-history.d.ts +8 -0
- package/dist/storage/scope-deletion-history.d.ts.map +1 -0
- package/dist/storage/scope-deletion-history.js +40 -0
- package/dist/storage/scope-deletion-history.js.map +1 -0
- package/dist/ui/ps-lite-debug.js +3981 -1109
- package/package.json +12 -4
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { encodeRecordKey, planIngest, RecordKeyError, summarizeIngest, decodeCursor, encodeCursor, InvalidCursorSyntaxError, CursorExpiredError, InvalidCursorError, BlobConflictError, } from "@opendatalabs/personal-server-ts-core/storage/pdpp-records";
|
|
4
|
+
import { isCompletionTime, liveSnapshotDigest, parseCompletionRequest, StreamCompletionError, } from "./pdpp-stream-completion.js";
|
|
5
|
+
export { StreamCompletionError, } from "./pdpp-stream-completion.js";
|
|
6
|
+
const RFC3339_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?(?:[Zz]|[+-](\d{2}):(\d{2}))$/;
|
|
7
|
+
/** RFC 3339 `date-time`, with calendar-valid fields. No clock check. */
|
|
8
|
+
export function isRfc3339DateTime(value) {
|
|
9
|
+
if (typeof value !== "string")
|
|
10
|
+
return false;
|
|
11
|
+
const match = RFC3339_DATE_TIME.exec(value);
|
|
12
|
+
if (!match)
|
|
13
|
+
return false;
|
|
14
|
+
const [year, month, day, hour, minute, second] = match
|
|
15
|
+
.slice(1, 7)
|
|
16
|
+
.map(Number);
|
|
17
|
+
const offsetHour = Number(match[8] ?? 0);
|
|
18
|
+
const offsetMinute = Number(match[9] ?? 0);
|
|
19
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
20
|
+
return (month >= 1 &&
|
|
21
|
+
month <= 12 &&
|
|
22
|
+
day >= 1 &&
|
|
23
|
+
day <= daysInMonth &&
|
|
24
|
+
hour <= 23 &&
|
|
25
|
+
minute <= 59 &&
|
|
26
|
+
second <= 59 &&
|
|
27
|
+
offsetHour <= 23 &&
|
|
28
|
+
offsetMinute <= 59 &&
|
|
29
|
+
!Number.isNaN(Date.parse(value)));
|
|
30
|
+
}
|
|
31
|
+
export class PdppBindingError extends Error {
|
|
32
|
+
reason;
|
|
33
|
+
constructor(reason) {
|
|
34
|
+
super(reason);
|
|
35
|
+
this.reason = reason;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function blobIdForBytes(bytes) {
|
|
39
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Schema migrations for the desktop SQLite backend of the PDPP record
|
|
43
|
+
* persistence index, applied in order and tracked in `pdpp_schema_version`
|
|
44
|
+
* so re-opening an existing database never re-runs or reinterprets an
|
|
45
|
+
* already-applied migration (C7: "no schema version, no ALTER path").
|
|
46
|
+
*
|
|
47
|
+
* `pdpp_records` holds current state (one row per instance+stream+record_key).
|
|
48
|
+
* `pdpp_record_changes` holds full version history for mutable_state streams
|
|
49
|
+
* (append_only streams only ever have one history row per key, at version 1,
|
|
50
|
+
* since duplicates are no-ops). `pdpp_blobs` holds blob metadata,
|
|
51
|
+
* `pdpp_blob_bytes` holds bytes, and `pdpp_blob_claims` ties pending uploads
|
|
52
|
+
* to an instance generation.
|
|
53
|
+
*/
|
|
54
|
+
const MIGRATIONS = [
|
|
55
|
+
// v1: initial schema.
|
|
56
|
+
`
|
|
57
|
+
CREATE TABLE IF NOT EXISTS pdpp_records (
|
|
58
|
+
instance TEXT NOT NULL,
|
|
59
|
+
stream TEXT NOT NULL,
|
|
60
|
+
record_key TEXT NOT NULL,
|
|
61
|
+
data TEXT, -- JSON; NULL when deleted = 1
|
|
62
|
+
version INTEGER NOT NULL,
|
|
63
|
+
emitted_at TEXT NOT NULL,
|
|
64
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
deleted_at TEXT,
|
|
66
|
+
PRIMARY KEY (instance, stream, record_key)
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS pdpp_record_changes (
|
|
70
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
instance TEXT NOT NULL,
|
|
72
|
+
stream TEXT NOT NULL,
|
|
73
|
+
record_key TEXT NOT NULL,
|
|
74
|
+
version INTEGER NOT NULL,
|
|
75
|
+
data TEXT, -- JSON; NULL for a tombstone version
|
|
76
|
+
emitted_at TEXT NOT NULL,
|
|
77
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
deleted_at TEXT,
|
|
79
|
+
written_at INTEGER NOT NULL -- monotonic write sequence, for changes_since horizons
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_record_changes_lookup
|
|
83
|
+
ON pdpp_record_changes (instance, stream, record_key, version);
|
|
84
|
+
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_record_changes_horizon
|
|
86
|
+
ON pdpp_record_changes (stream, instance, written_at);
|
|
87
|
+
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_records_stream
|
|
89
|
+
ON pdpp_records (stream, instance, emitted_at);
|
|
90
|
+
|
|
91
|
+
CREATE TABLE IF NOT EXISTS pdpp_blobs (
|
|
92
|
+
blob_id TEXT PRIMARY KEY,
|
|
93
|
+
mime_type TEXT NOT NULL,
|
|
94
|
+
size_bytes INTEGER NOT NULL,
|
|
95
|
+
sha256 TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS pdpp_write_clock (
|
|
99
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
100
|
+
value INTEGER NOT NULL
|
|
101
|
+
);
|
|
102
|
+
INSERT OR IGNORE INTO pdpp_write_clock (id, value) VALUES (1, 0);
|
|
103
|
+
`,
|
|
104
|
+
// v2: blob_ref reverse index (C2 fix — blob authorization must be able to
|
|
105
|
+
// find the record that references a blob_id, not trust field-name
|
|
106
|
+
// presence alone). Extracted from data.blob_ref.blob_id at write time so
|
|
107
|
+
// lookup is an indexed column read, not a per-request JSON scan.
|
|
108
|
+
`
|
|
109
|
+
ALTER TABLE pdpp_records ADD COLUMN blob_id TEXT;
|
|
110
|
+
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_records_blob_id
|
|
112
|
+
ON pdpp_records (blob_id);
|
|
113
|
+
`,
|
|
114
|
+
// v3: actual blob byte storage, separate from `pdpp_blobs` metadata so an
|
|
115
|
+
// existing metadata-only row (legacy fixture, or a blob whose bytes were
|
|
116
|
+
// never ingested by this store) is untouched by this migration and simply
|
|
117
|
+
// has no matching pdpp_blob_bytes row -- getBlobBytes reports that as
|
|
118
|
+
// "unavailable", not as corruption.
|
|
119
|
+
`
|
|
120
|
+
CREATE TABLE IF NOT EXISTS pdpp_blob_bytes (
|
|
121
|
+
blob_id TEXT PRIMARY KEY REFERENCES pdpp_blobs (blob_id),
|
|
122
|
+
bytes BLOB NOT NULL
|
|
123
|
+
);
|
|
124
|
+
`,
|
|
125
|
+
// v4: persist the method generation and generation-scoped blob claims.
|
|
126
|
+
// Existing instances remain unbound until their owner resets them.
|
|
127
|
+
`
|
|
128
|
+
CREATE TABLE IF NOT EXISTS pdpp_instance_binding (
|
|
129
|
+
instance TEXT PRIMARY KEY,
|
|
130
|
+
method TEXT,
|
|
131
|
+
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
132
|
+
reset_clock INTEGER NOT NULL DEFAULT 0
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
INSERT OR IGNORE INTO pdpp_instance_binding (instance, method, generation, reset_clock)
|
|
136
|
+
SELECT DISTINCT instance, NULL, 1, 0 FROM pdpp_records;
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS pdpp_blob_claims (
|
|
139
|
+
blob_id TEXT NOT NULL REFERENCES pdpp_blobs (blob_id),
|
|
140
|
+
instance TEXT NOT NULL,
|
|
141
|
+
generation INTEGER NOT NULL,
|
|
142
|
+
PRIMARY KEY (blob_id, instance)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
INSERT OR IGNORE INTO pdpp_blob_claims (blob_id, instance, generation)
|
|
146
|
+
SELECT DISTINCT r.blob_id, r.instance, b.generation
|
|
147
|
+
FROM pdpp_records r
|
|
148
|
+
JOIN pdpp_instance_binding b ON b.instance = r.instance
|
|
149
|
+
JOIN pdpp_blobs m ON m.blob_id = r.blob_id
|
|
150
|
+
WHERE r.blob_id IS NOT NULL AND r.deleted = 0;
|
|
151
|
+
`,
|
|
152
|
+
// v5: database epoch for opaque cursor binding. Tokens from a different
|
|
153
|
+
// records DB, a rebuilt records DB, or a pre-epoch implementation expire
|
|
154
|
+
// instead of being interpreted against this store.
|
|
155
|
+
`
|
|
156
|
+
CREATE TABLE IF NOT EXISTS pdpp_store_metadata (
|
|
157
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
158
|
+
epoch TEXT NOT NULL
|
|
159
|
+
);
|
|
160
|
+
`,
|
|
161
|
+
// v6: the instance handle is also the connection id. Keep its source,
|
|
162
|
+
// owner-facing label, and deletion tombstone on the existing binding row.
|
|
163
|
+
`
|
|
164
|
+
ALTER TABLE pdpp_instance_binding ADD COLUMN source_id TEXT;
|
|
165
|
+
ALTER TABLE pdpp_instance_binding ADD COLUMN label TEXT NOT NULL DEFAULT '';
|
|
166
|
+
ALTER TABLE pdpp_instance_binding ADD COLUMN deleted_at TEXT;
|
|
167
|
+
`,
|
|
168
|
+
// v7: completion evidence for P7. A row says the stream's current live set
|
|
169
|
+
// is exactly what one full replace by `method` in `generation` delivered,
|
|
170
|
+
// collected at the producer's `source_collected_at`. Only `replaceStream`
|
|
171
|
+
// writes it; any other write to the stream deletes it.
|
|
172
|
+
`
|
|
173
|
+
CREATE TABLE IF NOT EXISTS pdpp_stream_completion (
|
|
174
|
+
instance TEXT NOT NULL,
|
|
175
|
+
stream TEXT NOT NULL,
|
|
176
|
+
method TEXT NOT NULL,
|
|
177
|
+
generation INTEGER NOT NULL,
|
|
178
|
+
source_collected_at TEXT NOT NULL,
|
|
179
|
+
write_seq INTEGER NOT NULL,
|
|
180
|
+
live_count INTEGER NOT NULL,
|
|
181
|
+
PRIMARY KEY (instance, stream)
|
|
182
|
+
);
|
|
183
|
+
`,
|
|
184
|
+
// v8: arrival order for the legacy view. Desktop served a stream in the
|
|
185
|
+
// order each live key last arrived, and an unchanged re-delivery moved a
|
|
186
|
+
// key to the end without a new version. A row holds the latest arrival of
|
|
187
|
+
// one live key in the generation that wrote it. It is not record state:
|
|
188
|
+
// no version, emitted_at, history row or write clock moves with it. Rows
|
|
189
|
+
// stored before v8 have none, and none is guessed for them.
|
|
190
|
+
`
|
|
191
|
+
CREATE TABLE IF NOT EXISTS pdpp_record_arrival (
|
|
192
|
+
instance TEXT NOT NULL,
|
|
193
|
+
stream TEXT NOT NULL,
|
|
194
|
+
record_key TEXT NOT NULL,
|
|
195
|
+
generation INTEGER NOT NULL,
|
|
196
|
+
arrival INTEGER NOT NULL,
|
|
197
|
+
PRIMARY KEY (instance, stream, record_key)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_record_arrival_order
|
|
201
|
+
ON pdpp_record_arrival (instance, stream, arrival);
|
|
202
|
+
`,
|
|
203
|
+
// v9: native completion receipts (completeStreams). A receipt may now carry
|
|
204
|
+
// the digest of the live set it describes and how its time was taken. Both
|
|
205
|
+
// are null on every receipt P7 writes and on every receipt stored before
|
|
206
|
+
// v9, which stay producer-asserted and are never relabeled. The columns are
|
|
207
|
+
// added by `addCompletionProofColumns`, which keeps one that already exists
|
|
208
|
+
// only if it is a compatible nullable TEXT column, so a database whose
|
|
209
|
+
// version was only rewound can be upgraded again.
|
|
210
|
+
"",
|
|
211
|
+
// v10: cover the stream-summary query so it need not visit JSON record rows.
|
|
212
|
+
`
|
|
213
|
+
CREATE INDEX IF NOT EXISTS idx_pdpp_records_live_instance_stream_emitted_at
|
|
214
|
+
ON pdpp_records (instance, stream, emitted_at)
|
|
215
|
+
WHERE deleted = 0;
|
|
216
|
+
`,
|
|
217
|
+
// v11: monotonic cursor for arrival-only ordering changes in the legacy view.
|
|
218
|
+
`
|
|
219
|
+
CREATE TABLE IF NOT EXISTS pdpp_record_arrival_clock (
|
|
220
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
221
|
+
value INTEGER NOT NULL
|
|
222
|
+
);
|
|
223
|
+
INSERT OR IGNORE INTO pdpp_record_arrival_clock (id, value) VALUES (1, 0);
|
|
224
|
+
`,
|
|
225
|
+
];
|
|
226
|
+
function addCompletionProofColumns(db) {
|
|
227
|
+
// `table_xinfo`, unlike `table_info`, also lists generated columns.
|
|
228
|
+
const existing = new Map(db.prepare("PRAGMA table_xinfo(pdpp_stream_completion)").all().map((column) => [column.name, column]));
|
|
229
|
+
for (const name of ["snapshot_sha256", "collection_time_basis"]) {
|
|
230
|
+
const column = existing.get(name);
|
|
231
|
+
if (!column) {
|
|
232
|
+
db.exec(`ALTER TABLE pdpp_stream_completion ADD COLUMN ${name} TEXT`);
|
|
233
|
+
}
|
|
234
|
+
else if (column.type.toUpperCase() !== "TEXT" ||
|
|
235
|
+
column.notnull !== 0 ||
|
|
236
|
+
column.dflt_value !== null ||
|
|
237
|
+
column.pk !== 0 ||
|
|
238
|
+
column.hidden !== 0) {
|
|
239
|
+
throw new Error(`pdpp_stream_completion.${name} exists with an incompatible definition; v9 needs a nullable TEXT column with no default. Refusing to open.`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function migrate(db) {
|
|
244
|
+
db.exec(`
|
|
245
|
+
CREATE TABLE IF NOT EXISTS pdpp_schema_version (
|
|
246
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
247
|
+
version INTEGER NOT NULL
|
|
248
|
+
);
|
|
249
|
+
INSERT OR IGNORE INTO pdpp_schema_version (id, version) VALUES (1, 0);
|
|
250
|
+
`);
|
|
251
|
+
const current = db
|
|
252
|
+
.prepare("SELECT version FROM pdpp_schema_version WHERE id = 1")
|
|
253
|
+
.get();
|
|
254
|
+
const applyFrom = db.transaction((fromVersion) => {
|
|
255
|
+
for (let v = fromVersion; v < MIGRATIONS.length; v++) {
|
|
256
|
+
db.exec(MIGRATIONS[v]);
|
|
257
|
+
if (v === 8)
|
|
258
|
+
addCompletionProofColumns(db);
|
|
259
|
+
if (v === 4) {
|
|
260
|
+
db.prepare("INSERT OR IGNORE INTO pdpp_store_metadata (id, epoch) VALUES (1, ?)").run(randomUUID());
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
db.prepare("UPDATE pdpp_schema_version SET version = ? WHERE id = 1").run(MIGRATIONS.length);
|
|
264
|
+
});
|
|
265
|
+
if (current.version < MIGRATIONS.length) {
|
|
266
|
+
applyFrom(current.version);
|
|
267
|
+
}
|
|
268
|
+
else if (current.version > MIGRATIONS.length) {
|
|
269
|
+
// A newer schema version than this code knows how to read. Refuse to
|
|
270
|
+
// guess at compatibility rather than silently reinterpreting unknown
|
|
271
|
+
// state (matches the AS store's UnsupportedAuthStateError precedent).
|
|
272
|
+
throw new Error(`pdpp_records database schema version ${current.version} is newer than this build supports (${MIGRATIONS.length}). Refusing to open — upgrade this build before opening this database.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function sweepStaleBlobClaims(db) {
|
|
276
|
+
db.transaction(() => {
|
|
277
|
+
db.exec(`
|
|
278
|
+
DELETE FROM pdpp_blob_claims
|
|
279
|
+
WHERE NOT EXISTS (
|
|
280
|
+
SELECT 1 FROM pdpp_instance_binding b
|
|
281
|
+
WHERE b.instance = pdpp_blob_claims.instance
|
|
282
|
+
AND b.generation = pdpp_blob_claims.generation
|
|
283
|
+
);
|
|
284
|
+
`);
|
|
285
|
+
deleteUnclaimedBlobs(db);
|
|
286
|
+
})();
|
|
287
|
+
}
|
|
288
|
+
function deleteUnclaimedBlobs(db) {
|
|
289
|
+
db.exec(`
|
|
290
|
+
DELETE FROM pdpp_blob_bytes
|
|
291
|
+
WHERE blob_id IN (
|
|
292
|
+
SELECT blob_id FROM pdpp_blobs b
|
|
293
|
+
WHERE NOT EXISTS (SELECT 1 FROM pdpp_blob_claims c WHERE c.blob_id = b.blob_id)
|
|
294
|
+
AND NOT EXISTS (SELECT 1 FROM pdpp_records r WHERE r.blob_id = b.blob_id AND r.deleted = 0)
|
|
295
|
+
);
|
|
296
|
+
DELETE FROM pdpp_blobs
|
|
297
|
+
WHERE NOT EXISTS (SELECT 1 FROM pdpp_blob_claims c WHERE c.blob_id = pdpp_blobs.blob_id)
|
|
298
|
+
AND NOT EXISTS (SELECT 1 FROM pdpp_records r WHERE r.blob_id = pdpp_blobs.blob_id AND r.deleted = 0);
|
|
299
|
+
`);
|
|
300
|
+
}
|
|
301
|
+
function toBinding(row) {
|
|
302
|
+
return {
|
|
303
|
+
instance: row.instance,
|
|
304
|
+
sourceId: row.source_id,
|
|
305
|
+
label: row.label,
|
|
306
|
+
deletedAt: row.deleted_at,
|
|
307
|
+
method: row.method,
|
|
308
|
+
generation: row.generation,
|
|
309
|
+
resetClock: row.reset_clock,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
function ensureBinding(db, instance) {
|
|
313
|
+
db.prepare("INSERT OR IGNORE INTO pdpp_instance_binding (instance, method, generation, reset_clock) VALUES (?, NULL, 1, 0)").run(instance);
|
|
314
|
+
return db
|
|
315
|
+
.prepare("SELECT * FROM pdpp_instance_binding WHERE instance = ?")
|
|
316
|
+
.get(instance);
|
|
317
|
+
}
|
|
318
|
+
// A horizon that is not a non-negative integer would compare false against
|
|
319
|
+
// every reset_clock and pass the P10c fence, so it is refused.
|
|
320
|
+
function parseHorizon(value) {
|
|
321
|
+
const horizon = Number(value);
|
|
322
|
+
if (!/^\d+$/.test(String(value)) || !Number.isSafeInteger(horizon)) {
|
|
323
|
+
throw new InvalidCursorError();
|
|
324
|
+
}
|
|
325
|
+
return horizon;
|
|
326
|
+
}
|
|
327
|
+
function parseOffset(value) {
|
|
328
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
329
|
+
throw new InvalidCursorError();
|
|
330
|
+
}
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
function instanceHasRecords(db, instance) {
|
|
334
|
+
const row = db
|
|
335
|
+
.prepare(`SELECT EXISTS(SELECT 1 FROM pdpp_records WHERE instance = ?)
|
|
336
|
+
OR EXISTS(SELECT 1 FROM pdpp_record_changes WHERE instance = ?) AS has_records`)
|
|
337
|
+
.get(instance, instance);
|
|
338
|
+
return row.has_records === 1;
|
|
339
|
+
}
|
|
340
|
+
function extractBlobId(data) {
|
|
341
|
+
if (!data)
|
|
342
|
+
return null;
|
|
343
|
+
const blobRef = data.blob_ref;
|
|
344
|
+
if (blobRef &&
|
|
345
|
+
typeof blobRef === "object" &&
|
|
346
|
+
typeof blobRef.blob_id === "string") {
|
|
347
|
+
return blobRef.blob_id;
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
function toRow(db) {
|
|
352
|
+
if (db.deleted) {
|
|
353
|
+
return {
|
|
354
|
+
instance: db.instance,
|
|
355
|
+
stream: db.stream,
|
|
356
|
+
recordKey: db.record_key,
|
|
357
|
+
version: db.version,
|
|
358
|
+
deletedAt: db.deleted_at ?? db.emitted_at,
|
|
359
|
+
emittedAt: db.emitted_at,
|
|
360
|
+
deleted: true,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
instance: db.instance,
|
|
365
|
+
stream: db.stream,
|
|
366
|
+
recordKey: db.record_key,
|
|
367
|
+
data: db.data ? JSON.parse(db.data) : {},
|
|
368
|
+
version: db.version,
|
|
369
|
+
emittedAt: db.emitted_at,
|
|
370
|
+
deleted: false,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function projectFields(data, fields) {
|
|
374
|
+
if (!fields)
|
|
375
|
+
return data;
|
|
376
|
+
const projected = {};
|
|
377
|
+
for (const field of fields) {
|
|
378
|
+
if (field in data)
|
|
379
|
+
projected[field] = data[field];
|
|
380
|
+
}
|
|
381
|
+
return projected;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* SQLite-backed PdppRecordStore for desktop persistence. Requires
|
|
385
|
+
* `better-sqlite3` (already a `packages/server` dependency in this repo).
|
|
386
|
+
* A readonly database must already have the current schema; no migration,
|
|
387
|
+
* journal-mode change or stale-blob sweep runs. SQLite rejects write methods.
|
|
388
|
+
*/
|
|
389
|
+
export function createSqliteRecordStore(db) {
|
|
390
|
+
if (db.readonly) {
|
|
391
|
+
const current = db
|
|
392
|
+
.prepare("SELECT version FROM pdpp_schema_version WHERE id = 1")
|
|
393
|
+
.get();
|
|
394
|
+
if (current?.version !== MIGRATIONS.length) {
|
|
395
|
+
throw new Error("Readonly PDPP store requires the current schema version");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
db.pragma("journal_mode = WAL");
|
|
400
|
+
migrate(db);
|
|
401
|
+
sweepStaleBlobClaims(db);
|
|
402
|
+
}
|
|
403
|
+
const nextWriteSeq = db.prepare("UPDATE pdpp_write_clock SET value = value + 1 WHERE id = 1 RETURNING value");
|
|
404
|
+
const readWriteClockStmt = db.prepare("SELECT value FROM pdpp_write_clock WHERE id = 1");
|
|
405
|
+
const readWriteClock = () => readWriteClockStmt.get().value;
|
|
406
|
+
const getCurrentStmt = db.prepare("SELECT * FROM pdpp_records WHERE instance = ? AND stream = ? AND record_key = ?");
|
|
407
|
+
const upsertCurrentStmt = db.prepare(`
|
|
408
|
+
INSERT INTO pdpp_records (instance, stream, record_key, data, version, emitted_at, deleted, deleted_at, blob_id)
|
|
409
|
+
VALUES (@instance, @stream, @record_key, @data, @version, @emitted_at, @deleted, @deleted_at, @blob_id)
|
|
410
|
+
ON CONFLICT (instance, stream, record_key) DO UPDATE SET
|
|
411
|
+
data = excluded.data,
|
|
412
|
+
version = excluded.version,
|
|
413
|
+
emitted_at = excluded.emitted_at,
|
|
414
|
+
deleted = excluded.deleted,
|
|
415
|
+
deleted_at = excluded.deleted_at,
|
|
416
|
+
blob_id = excluded.blob_id
|
|
417
|
+
`);
|
|
418
|
+
const insertHistoryStmt = db.prepare(`
|
|
419
|
+
INSERT INTO pdpp_record_changes
|
|
420
|
+
(instance, stream, record_key, version, data, emitted_at, deleted, deleted_at, written_at)
|
|
421
|
+
VALUES (@instance, @stream, @record_key, @version, @data, @emitted_at, @deleted, @deleted_at, @written_at)
|
|
422
|
+
`);
|
|
423
|
+
const putBlobStmt = db.prepare(`
|
|
424
|
+
INSERT INTO pdpp_blobs (blob_id, mime_type, size_bytes, sha256)
|
|
425
|
+
VALUES (@blob_id, @mime_type, @size_bytes, @sha256)
|
|
426
|
+
ON CONFLICT (blob_id) DO UPDATE SET
|
|
427
|
+
mime_type = excluded.mime_type, size_bytes = excluded.size_bytes, sha256 = excluded.sha256
|
|
428
|
+
`);
|
|
429
|
+
const getBlobStmt = db.prepare("SELECT * FROM pdpp_blobs WHERE blob_id = ?");
|
|
430
|
+
const findBlobRefsStmt = db.prepare("SELECT instance, stream, record_key FROM pdpp_records WHERE blob_id = ? AND deleted = 0");
|
|
431
|
+
const insertBlobBytesStmt = db.prepare("INSERT INTO pdpp_blob_bytes (blob_id, bytes) VALUES (@blob_id, @bytes)");
|
|
432
|
+
const getBlobBytesStmt = db.prepare("SELECT bytes FROM pdpp_blob_bytes WHERE blob_id = ?");
|
|
433
|
+
const dropCompletionStmt = db.prepare("DELETE FROM pdpp_stream_completion WHERE instance = ? AND stream = ?");
|
|
434
|
+
const noteArrivalStmt = db.prepare(`
|
|
435
|
+
INSERT INTO pdpp_record_arrival (instance, stream, record_key, generation, arrival)
|
|
436
|
+
VALUES (@instance, @stream, @record_key, @generation, (
|
|
437
|
+
SELECT COALESCE(MAX(arrival), 0) + 1 FROM pdpp_record_arrival
|
|
438
|
+
WHERE instance = @instance AND stream = @stream
|
|
439
|
+
))
|
|
440
|
+
ON CONFLICT (instance, stream, record_key) DO UPDATE SET
|
|
441
|
+
generation = excluded.generation,
|
|
442
|
+
arrival = excluded.arrival
|
|
443
|
+
`);
|
|
444
|
+
const forgetArrivalStmt = db.prepare("DELETE FROM pdpp_record_arrival WHERE instance = ? AND stream = ? AND record_key = ?");
|
|
445
|
+
/** `recordKey` is now the last live key of its stream to arrive. */
|
|
446
|
+
function noteArrival(instance, stream, recordKey, generation) {
|
|
447
|
+
noteArrivalStmt.run({
|
|
448
|
+
instance,
|
|
449
|
+
stream,
|
|
450
|
+
record_key: recordKey,
|
|
451
|
+
generation,
|
|
452
|
+
});
|
|
453
|
+
db.prepare("UPDATE pdpp_record_arrival_clock SET value = value + 1 WHERE id = 1").run();
|
|
454
|
+
}
|
|
455
|
+
function latestVersion(instance, stream, recordKey) {
|
|
456
|
+
const row = getCurrentStmt.get(instance, stream, recordKey);
|
|
457
|
+
return row?.version ?? 0;
|
|
458
|
+
}
|
|
459
|
+
function ingestBatch(envelopes, streamSemantics, primaryKeyFields, binding, validateData) {
|
|
460
|
+
const results = [];
|
|
461
|
+
// The whole batch is one SQLite transaction: a thrown error rolls back
|
|
462
|
+
// every write from this call, never leaving partial version/
|
|
463
|
+
// record_changes state. A rejected envelope is an outcome, not a
|
|
464
|
+
// failure: it writes nothing and the rest of the batch proceeds.
|
|
465
|
+
// Reads inside the transaction see earlier writes of the same batch, so
|
|
466
|
+
// two envelopes for one key are decided in order.
|
|
467
|
+
const runBatch = db.transaction(() => {
|
|
468
|
+
if (!binding) {
|
|
469
|
+
// P8a: every canonical write names its method and generation. A write
|
|
470
|
+
// without them (the sync importer, which has no method identity)
|
|
471
|
+
// cannot be checked against the binding or the blob claims, so it is
|
|
472
|
+
// refused whether or not the instance has a binding row yet.
|
|
473
|
+
envelopes.forEach((_, index) => results.push({
|
|
474
|
+
index,
|
|
475
|
+
outcome: "rejected",
|
|
476
|
+
reason: "method_required",
|
|
477
|
+
}));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const { generation } = binding;
|
|
481
|
+
if (envelopes.length > 0) {
|
|
482
|
+
checkBinding(binding.method, generation, envelopes);
|
|
483
|
+
}
|
|
484
|
+
envelopes.forEach((envelope, index) => {
|
|
485
|
+
const plan = planIngest(index, envelope, streamSemantics(envelope.stream, envelope.instance), primaryKeyFields(envelope.stream, envelope.instance), (recordKey) => {
|
|
486
|
+
const row = getCurrentStmt.get(envelope.instance, envelope.stream, recordKey);
|
|
487
|
+
return row
|
|
488
|
+
? {
|
|
489
|
+
deleted: row.deleted === 1,
|
|
490
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
491
|
+
}
|
|
492
|
+
: undefined;
|
|
493
|
+
}, validateData);
|
|
494
|
+
results.push(plan.outcome);
|
|
495
|
+
if (!plan.write) {
|
|
496
|
+
// An equal re-delivery is still an arrival. A delete of a missing
|
|
497
|
+
// key and an append_only conflict are not.
|
|
498
|
+
if (plan.outcome.outcome === "unchanged" &&
|
|
499
|
+
!plan.outcome.flag &&
|
|
500
|
+
envelope.op !== "delete") {
|
|
501
|
+
noteArrival(envelope.instance, envelope.stream, encodeRecordKey(envelope.key), generation);
|
|
502
|
+
}
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const { recordKey, data } = plan.write;
|
|
506
|
+
const blobId = extractBlobId(data);
|
|
507
|
+
if (binding &&
|
|
508
|
+
blobId &&
|
|
509
|
+
!blobClaimedByCurrentGeneration(blobId, envelope.instance)) {
|
|
510
|
+
results[results.length - 1] = {
|
|
511
|
+
index,
|
|
512
|
+
outcome: "rejected",
|
|
513
|
+
reason: "blob_unclaimed",
|
|
514
|
+
};
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
writeVersion(envelope.instance, envelope.stream, recordKey, data, envelope.emitted_at);
|
|
518
|
+
if (data === null) {
|
|
519
|
+
forgetArrivalStmt.run(envelope.instance, envelope.stream, recordKey);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
noteArrival(envelope.instance, envelope.stream, recordKey, generation);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
runBatch();
|
|
527
|
+
return summarizeIngest(results);
|
|
528
|
+
}
|
|
529
|
+
function blobClaimedByCurrentGeneration(blobId, instance) {
|
|
530
|
+
return !!db
|
|
531
|
+
.prepare("SELECT 1 FROM pdpp_blob_claims WHERE blob_id = ? AND instance = ? AND generation = (SELECT generation FROM pdpp_instance_binding WHERE instance = ?)")
|
|
532
|
+
.get(blobId, instance, instance);
|
|
533
|
+
}
|
|
534
|
+
/** Writes the next version of a key: `data === null` is a tombstone. */
|
|
535
|
+
function writeVersion(instance, stream, recordKey, data, emittedAt) {
|
|
536
|
+
const deleted = data === null;
|
|
537
|
+
const version = latestVersion(instance, stream, recordKey) + 1;
|
|
538
|
+
const writtenAt = nextWriteSeq.get();
|
|
539
|
+
// The stream no longer holds what a replace delivered. `replaceStream`
|
|
540
|
+
// writes its own receipt after its writes.
|
|
541
|
+
dropCompletionStmt.run(instance, stream);
|
|
542
|
+
const dataJson = deleted ? null : JSON.stringify(data);
|
|
543
|
+
const deletedAt = deleted ? emittedAt : null;
|
|
544
|
+
upsertCurrentStmt.run({
|
|
545
|
+
instance,
|
|
546
|
+
stream,
|
|
547
|
+
record_key: recordKey,
|
|
548
|
+
data: dataJson,
|
|
549
|
+
version,
|
|
550
|
+
emitted_at: emittedAt,
|
|
551
|
+
deleted: deleted ? 1 : 0,
|
|
552
|
+
deleted_at: deletedAt,
|
|
553
|
+
blob_id: extractBlobId(data),
|
|
554
|
+
});
|
|
555
|
+
insertHistoryStmt.run({
|
|
556
|
+
instance,
|
|
557
|
+
stream,
|
|
558
|
+
record_key: recordKey,
|
|
559
|
+
version,
|
|
560
|
+
data: dataJson,
|
|
561
|
+
emitted_at: emittedAt,
|
|
562
|
+
deleted: deleted ? 1 : 0,
|
|
563
|
+
deleted_at: deletedAt,
|
|
564
|
+
written_at: writtenAt.value,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// P7: the records are the complete live set of (instance, stream) from a
|
|
568
|
+
// covered full refresh by the bound method. Under Q1 every live row of the
|
|
569
|
+
// instance was written by that method in this generation, so a live key the
|
|
570
|
+
// snapshot lacks has no other evidence and is tombstoned. Any rejected
|
|
571
|
+
// record rejects the whole request before anything is written.
|
|
572
|
+
//
|
|
573
|
+
// With `sourceCollectedAt`, an applied replace also stores its completion
|
|
574
|
+
// receipt in the same transaction. Without it there is no receipt, and an
|
|
575
|
+
// older one is deleted: it would describe a collection that is no longer
|
|
576
|
+
// the latest.
|
|
577
|
+
function replaceStream(input) {
|
|
578
|
+
if (input.sourceCollectedAt !== undefined &&
|
|
579
|
+
!isRfc3339DateTime(input.sourceCollectedAt)) {
|
|
580
|
+
throw new TypeError("invalid_source_collected_at");
|
|
581
|
+
}
|
|
582
|
+
if (input.completionPolicy !== undefined &&
|
|
583
|
+
(input.completionPolicy !== "preserve_if_unchanged" ||
|
|
584
|
+
input.sourceCollectedAt !== undefined)) {
|
|
585
|
+
throw new TypeError("invalid_completion_policy");
|
|
586
|
+
}
|
|
587
|
+
return db.transaction(() => {
|
|
588
|
+
checkInstanceBinding(input.instance, input.method, input.generation);
|
|
589
|
+
const seen = new Set();
|
|
590
|
+
/** Live keys of the snapshot, in the order it delivered them. */
|
|
591
|
+
const snapshotOrder = [];
|
|
592
|
+
const results = [];
|
|
593
|
+
const writes = [];
|
|
594
|
+
input.envelopes.forEach((envelope, index) => {
|
|
595
|
+
const reject = (reason) => results.push({ index, outcome: "rejected", reason });
|
|
596
|
+
let recordKey;
|
|
597
|
+
try {
|
|
598
|
+
recordKey = encodeRecordKey(envelope.key);
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
if (err instanceof RecordKeyError)
|
|
602
|
+
return reject(err.message);
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
if (seen.has(recordKey))
|
|
606
|
+
return reject("duplicate key in snapshot");
|
|
607
|
+
seen.add(recordKey);
|
|
608
|
+
const plan = planIngest(index, envelope, "mutable_state", input.primaryKey, (key) => {
|
|
609
|
+
const row = getCurrentStmt.get(input.instance, input.stream, key);
|
|
610
|
+
return row
|
|
611
|
+
? {
|
|
612
|
+
deleted: row.deleted === 1,
|
|
613
|
+
data: row.data ? JSON.parse(row.data) : null,
|
|
614
|
+
}
|
|
615
|
+
: undefined;
|
|
616
|
+
}, input.validateData);
|
|
617
|
+
if (plan.outcome.outcome === "rejected") {
|
|
618
|
+
return void results.push(plan.outcome);
|
|
619
|
+
}
|
|
620
|
+
if (envelope.op !== "delete")
|
|
621
|
+
snapshotOrder.push(recordKey);
|
|
622
|
+
if (!plan.write)
|
|
623
|
+
return void results.push(plan.outcome);
|
|
624
|
+
const blobId = extractBlobId(plan.write.data);
|
|
625
|
+
if (blobId && !blobClaimedByCurrentGeneration(blobId, input.instance)) {
|
|
626
|
+
return reject("blob_unclaimed");
|
|
627
|
+
}
|
|
628
|
+
results.push(plan.outcome);
|
|
629
|
+
writes.push({
|
|
630
|
+
recordKey,
|
|
631
|
+
data: plan.write.data,
|
|
632
|
+
emittedAt: envelope.emitted_at,
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
const summary = summarizeIngest(results);
|
|
636
|
+
if (summary.rejected.length > 0) {
|
|
637
|
+
return { ...summary, applied: false, deleted: 0 };
|
|
638
|
+
}
|
|
639
|
+
const preserved = input.completionPolicy
|
|
640
|
+
? validReceiptOfUnchangedSet(input, writes.length, seen)
|
|
641
|
+
: false;
|
|
642
|
+
for (const write of writes) {
|
|
643
|
+
writeVersion(input.instance, input.stream, write.recordKey, write.data, write.emittedAt);
|
|
644
|
+
}
|
|
645
|
+
const missing = db
|
|
646
|
+
.prepare("SELECT record_key FROM pdpp_records WHERE instance = ? AND stream = ? AND deleted = 0")
|
|
647
|
+
.all(input.instance, input.stream).filter(({ record_key }) => !seen.has(record_key));
|
|
648
|
+
for (const { record_key } of missing) {
|
|
649
|
+
writeVersion(input.instance, input.stream, record_key, null, input.emittedAt);
|
|
650
|
+
}
|
|
651
|
+
// Desktop rebuilt a fully refreshed stream in emit order, unchanged
|
|
652
|
+
// keys included.
|
|
653
|
+
db.prepare("DELETE FROM pdpp_record_arrival WHERE instance = ? AND stream = ?").run(input.instance, input.stream);
|
|
654
|
+
for (const recordKey of snapshotOrder) {
|
|
655
|
+
noteArrival(input.instance, input.stream, recordKey, input.generation);
|
|
656
|
+
}
|
|
657
|
+
if (!preserved)
|
|
658
|
+
dropCompletionStmt.run(input.instance, input.stream);
|
|
659
|
+
if (input.sourceCollectedAt !== undefined) {
|
|
660
|
+
db.prepare(`INSERT INTO pdpp_stream_completion
|
|
661
|
+
(instance, stream, method, generation, source_collected_at, write_seq, live_count)
|
|
662
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.instance, input.stream, input.method, input.generation, input.sourceCollectedAt, readWriteClock(), db
|
|
663
|
+
.prepare("SELECT COUNT(*) AS n FROM pdpp_records WHERE instance = ? AND stream = ? AND deleted = 0")
|
|
664
|
+
.get(input.instance, input.stream).n);
|
|
665
|
+
}
|
|
666
|
+
return { ...summary, applied: true, deleted: missing.length };
|
|
667
|
+
})();
|
|
668
|
+
}
|
|
669
|
+
// Under `preserve_if_unchanged`: true when this replace writes nothing to
|
|
670
|
+
// the stream's rows, no live key is missing from the snapshot, and the
|
|
671
|
+
// stream's current receipt still describes the live set. The receipt is then
|
|
672
|
+
// left as stored. Changed content is judged by `planIngest` (`writes` is
|
|
673
|
+
// empty only when every snapshot key is live and equal), and a retained
|
|
674
|
+
// extra key shows up as a live key the snapshot lacks. Anything else,
|
|
675
|
+
// including a stale or tampered receipt, returns false and the caller drops
|
|
676
|
+
// the receipt as P7 always has.
|
|
677
|
+
function validReceiptOfUnchangedSet(input, writeCount, seen) {
|
|
678
|
+
if (writeCount > 0)
|
|
679
|
+
return false;
|
|
680
|
+
const rows = db
|
|
681
|
+
.prepare("SELECT record_key, data FROM pdpp_records WHERE instance = ? AND stream = ? AND deleted = 0")
|
|
682
|
+
.all(input.instance, input.stream);
|
|
683
|
+
if (rows.some((row) => !seen.has(row.record_key)))
|
|
684
|
+
return false;
|
|
685
|
+
const prior = db
|
|
686
|
+
.prepare("SELECT * FROM pdpp_stream_completion WHERE instance = ? AND stream = ?")
|
|
687
|
+
.get(input.instance, input.stream);
|
|
688
|
+
if (!prior)
|
|
689
|
+
return false;
|
|
690
|
+
const binding = db
|
|
691
|
+
.prepare("SELECT source_id FROM pdpp_instance_binding WHERE instance = ?")
|
|
692
|
+
.get(input.instance);
|
|
693
|
+
try {
|
|
694
|
+
return receiptInvariantsHold(prior, {
|
|
695
|
+
method: input.method,
|
|
696
|
+
generation: input.generation,
|
|
697
|
+
liveCount: rows.length,
|
|
698
|
+
clock: readWriteClock(),
|
|
699
|
+
lastChange: lastChangeSeq(input.instance, input.stream),
|
|
700
|
+
digest: () => liveSnapshotDigest({ ...input, sourceId: binding.source_id }, input.primaryKey, rows.map((row) => ({
|
|
701
|
+
recordKey: row.record_key,
|
|
702
|
+
dataJson: row.data,
|
|
703
|
+
}))),
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
// A live set the digest cannot describe cannot match a native receipt.
|
|
708
|
+
if (error instanceof StreamCompletionError)
|
|
709
|
+
return false;
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function checkBinding(method, generation, envelopes) {
|
|
714
|
+
const instances = new Set(envelopes.map((envelope) => envelope.instance));
|
|
715
|
+
if (instances.size !== 1)
|
|
716
|
+
throw new PdppBindingError("invalid_request");
|
|
717
|
+
checkInstanceBinding([...instances][0], method, generation);
|
|
718
|
+
}
|
|
719
|
+
/** Check every write against the connection's registered method/generation. */
|
|
720
|
+
function checkInstanceBinding(instance, method, generation) {
|
|
721
|
+
// Only a named method may write.
|
|
722
|
+
if (typeof method !== "string" || method.length === 0) {
|
|
723
|
+
throw new PdppBindingError("method_required");
|
|
724
|
+
}
|
|
725
|
+
const current = ensureBinding(db, instance);
|
|
726
|
+
if (current.deleted_at !== null) {
|
|
727
|
+
throw new PdppBindingError("connection_deleted");
|
|
728
|
+
}
|
|
729
|
+
if (current.source_id === null) {
|
|
730
|
+
throw new PdppBindingError("connection_required");
|
|
731
|
+
}
|
|
732
|
+
if (current.generation !== generation) {
|
|
733
|
+
throw new PdppBindingError("binding_generation_mismatch");
|
|
734
|
+
}
|
|
735
|
+
if (current.method !== method) {
|
|
736
|
+
throw new PdppBindingError("instance_bound_to_other_method");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Read-only: an instance with no row reports the row `ensureBinding` would
|
|
740
|
+
// create, without creating it.
|
|
741
|
+
function getInstanceBinding(instance) {
|
|
742
|
+
const row = db
|
|
743
|
+
.prepare("SELECT * FROM pdpp_instance_binding WHERE instance = ?")
|
|
744
|
+
.get(instance) ?? {
|
|
745
|
+
instance,
|
|
746
|
+
source_id: null,
|
|
747
|
+
label: "",
|
|
748
|
+
deleted_at: null,
|
|
749
|
+
method: null,
|
|
750
|
+
generation: 1,
|
|
751
|
+
reset_clock: 0,
|
|
752
|
+
};
|
|
753
|
+
return {
|
|
754
|
+
...toBinding(row),
|
|
755
|
+
empty: !instanceHasRecords(db, instance),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
function resetInstanceBinding(input) {
|
|
759
|
+
return db.transaction(() => {
|
|
760
|
+
const current = ensureBinding(db, input.instance);
|
|
761
|
+
if (current.deleted_at !== null) {
|
|
762
|
+
throw new PdppBindingError("connection_deleted");
|
|
763
|
+
}
|
|
764
|
+
if (input.instance.startsWith("conn_") && current.source_id === null) {
|
|
765
|
+
throw new PdppBindingError("connection_required");
|
|
766
|
+
}
|
|
767
|
+
if (current.method !== input.expectedMethod ||
|
|
768
|
+
current.generation !== input.expectedGeneration) {
|
|
769
|
+
const alreadyReset = current.method === input.nextMethod &&
|
|
770
|
+
current.generation === input.expectedGeneration + 1 &&
|
|
771
|
+
!instanceHasRecords(db, input.instance);
|
|
772
|
+
if (alreadyReset)
|
|
773
|
+
return { binding: toBinding(current), alreadyReset: true };
|
|
774
|
+
throw new PdppBindingError("binding_generation_mismatch");
|
|
775
|
+
}
|
|
776
|
+
// A registered connection has one method for its lifetime. Reset may
|
|
777
|
+
// erase its data and advance the generation, but it cannot rebind the
|
|
778
|
+
// connection to another method.
|
|
779
|
+
if (current.source_id !== null && current.method !== input.nextMethod) {
|
|
780
|
+
throw new PdppBindingError("connection_conflict");
|
|
781
|
+
}
|
|
782
|
+
eraseInstanceData(input.instance);
|
|
783
|
+
const resetClock = nextWriteSeq.get().value;
|
|
784
|
+
db.prepare("UPDATE pdpp_instance_binding SET method = ?, generation = ?, reset_clock = ? WHERE instance = ?").run(input.nextMethod, input.expectedGeneration + 1, resetClock, input.instance);
|
|
785
|
+
return {
|
|
786
|
+
binding: toBinding(ensureBinding(db, input.instance)),
|
|
787
|
+
alreadyReset: false,
|
|
788
|
+
};
|
|
789
|
+
})();
|
|
790
|
+
}
|
|
791
|
+
function listConnections(sourceId) {
|
|
792
|
+
const rows = db
|
|
793
|
+
.prepare("SELECT * FROM pdpp_instance_binding WHERE source_id = ? AND deleted_at IS NULL ORDER BY instance")
|
|
794
|
+
.all(sourceId);
|
|
795
|
+
return rows.map(toBinding);
|
|
796
|
+
}
|
|
797
|
+
function registerConnection(input) {
|
|
798
|
+
return db.transaction(() => {
|
|
799
|
+
const current = ensureBinding(db, input.instance);
|
|
800
|
+
if (current.deleted_at !== null) {
|
|
801
|
+
throw new PdppBindingError("connection_deleted");
|
|
802
|
+
}
|
|
803
|
+
if (current.source_id !== null &&
|
|
804
|
+
(current.source_id !== input.sourceId ||
|
|
805
|
+
current.method !== input.method)) {
|
|
806
|
+
throw new PdppBindingError("connection_conflict");
|
|
807
|
+
}
|
|
808
|
+
if (current.method !== null && current.method !== input.method) {
|
|
809
|
+
throw new PdppBindingError("connection_conflict");
|
|
810
|
+
}
|
|
811
|
+
db.prepare("UPDATE pdpp_instance_binding SET source_id = ?, method = ?, label = ? WHERE instance = ?").run(input.sourceId, input.method, input.label, input.instance);
|
|
812
|
+
return getInstanceBinding(input.instance);
|
|
813
|
+
})();
|
|
814
|
+
}
|
|
815
|
+
function deleteConnection(instance) {
|
|
816
|
+
return db.transaction(() => {
|
|
817
|
+
const current = ensureBinding(db, instance);
|
|
818
|
+
if (current.deleted_at !== null)
|
|
819
|
+
return toBinding(current);
|
|
820
|
+
eraseInstanceData(instance);
|
|
821
|
+
const resetClock = nextWriteSeq.get().value;
|
|
822
|
+
const deletedAt = new Date().toISOString();
|
|
823
|
+
db.prepare("UPDATE pdpp_instance_binding SET deleted_at = ?, generation = generation + 1, reset_clock = ? WHERE instance = ?").run(deletedAt, resetClock, instance);
|
|
824
|
+
return getInstanceBinding(instance);
|
|
825
|
+
})();
|
|
826
|
+
}
|
|
827
|
+
function eraseInstanceData(instance) {
|
|
828
|
+
db.prepare("DELETE FROM pdpp_records WHERE instance = ?").run(instance);
|
|
829
|
+
db.prepare("DELETE FROM pdpp_record_changes WHERE instance = ?").run(instance);
|
|
830
|
+
db.prepare("DELETE FROM pdpp_blob_claims WHERE instance = ?").run(instance);
|
|
831
|
+
db.prepare("DELETE FROM pdpp_stream_completion WHERE instance = ?").run(instance);
|
|
832
|
+
db.prepare("DELETE FROM pdpp_record_arrival WHERE instance = ?").run(instance);
|
|
833
|
+
deleteUnclaimedBlobs(db);
|
|
834
|
+
}
|
|
835
|
+
function listStreamCompletions(instance) {
|
|
836
|
+
const rows = db
|
|
837
|
+
.prepare("SELECT * FROM pdpp_stream_completion WHERE instance = ? ORDER BY stream")
|
|
838
|
+
.all(instance);
|
|
839
|
+
return rows.map((row) => ({
|
|
840
|
+
instance: row.instance,
|
|
841
|
+
stream: row.stream,
|
|
842
|
+
method: row.method,
|
|
843
|
+
generation: row.generation,
|
|
844
|
+
sourceCollectedAt: row.source_collected_at,
|
|
845
|
+
writeSeq: row.write_seq,
|
|
846
|
+
liveCount: row.live_count,
|
|
847
|
+
}));
|
|
848
|
+
}
|
|
849
|
+
function lastChangeSeq(instance, stream) {
|
|
850
|
+
return db
|
|
851
|
+
.prepare("SELECT COALESCE(MAX(written_at), 0) AS seq FROM pdpp_record_changes WHERE instance = ? AND stream = ?")
|
|
852
|
+
.get(instance, stream).seq;
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Whether a stored receipt still describes the live set it was written
|
|
856
|
+
* for. It never compares the write clock to the global one: unrelated
|
|
857
|
+
* writes move that without touching this stream.
|
|
858
|
+
*/
|
|
859
|
+
function receiptInvariantsHold(prior, expected) {
|
|
860
|
+
const native = prior.snapshot_sha256 !== null;
|
|
861
|
+
return (prior.method === expected.method &&
|
|
862
|
+
prior.generation === expected.generation &&
|
|
863
|
+
prior.live_count === expected.liveCount &&
|
|
864
|
+
Number.isSafeInteger(prior.write_seq) &&
|
|
865
|
+
prior.write_seq >= 0 &&
|
|
866
|
+
prior.write_seq <= expected.clock &&
|
|
867
|
+
prior.write_seq >= expected.lastChange &&
|
|
868
|
+
(native
|
|
869
|
+
? isCompletionTime(prior.source_collected_at)
|
|
870
|
+
: isRfc3339DateTime(prior.source_collected_at)) &&
|
|
871
|
+
native === (prior.collection_time_basis !== null) &&
|
|
872
|
+
(!native ||
|
|
873
|
+
(prior.snapshot_sha256 === expected.digest() &&
|
|
874
|
+
prior.collection_time_basis === "host_run_finished")));
|
|
875
|
+
}
|
|
876
|
+
function completeStreams(rawRequest, declarations) {
|
|
877
|
+
const request = parseCompletionRequest(rawRequest);
|
|
878
|
+
return db
|
|
879
|
+
.transaction(() => {
|
|
880
|
+
const binding = db
|
|
881
|
+
.prepare("SELECT * FROM pdpp_instance_binding WHERE instance = ?")
|
|
882
|
+
.get(request.instance);
|
|
883
|
+
if (!binding || binding.source_id === null) {
|
|
884
|
+
throw new PdppBindingError("connection_required");
|
|
885
|
+
}
|
|
886
|
+
if (binding.deleted_at !== null) {
|
|
887
|
+
throw new PdppBindingError("connection_deleted");
|
|
888
|
+
}
|
|
889
|
+
if (binding.source_id !== request.sourceId) {
|
|
890
|
+
throw new PdppBindingError("source_mismatch");
|
|
891
|
+
}
|
|
892
|
+
if (binding.generation !== request.generation) {
|
|
893
|
+
throw new PdppBindingError("binding_generation_mismatch");
|
|
894
|
+
}
|
|
895
|
+
if (binding.method !== request.method) {
|
|
896
|
+
throw new PdppBindingError("instance_bound_to_other_method");
|
|
897
|
+
}
|
|
898
|
+
const clock = readWriteClock();
|
|
899
|
+
const requestedAt = Date.parse(request.sourceCollectedAt);
|
|
900
|
+
// Validate every stream before the first receipt is written.
|
|
901
|
+
const writes = [];
|
|
902
|
+
for (const proof of request.streams) {
|
|
903
|
+
const primaryKey = declarations(request.sourceId, proof.stream);
|
|
904
|
+
if (primaryKey === undefined) {
|
|
905
|
+
throw new StreamCompletionError("unknown_stream");
|
|
906
|
+
}
|
|
907
|
+
if (primaryKey.length === 0 ||
|
|
908
|
+
!primaryKey.every((field) => typeof field === "string" && field)) {
|
|
909
|
+
throw new StreamCompletionError("invalid_declaration");
|
|
910
|
+
}
|
|
911
|
+
const rows = db
|
|
912
|
+
.prepare("SELECT record_key, data FROM pdpp_records WHERE instance = ? AND stream = ? AND deleted = 0")
|
|
913
|
+
.all(request.instance, proof.stream);
|
|
914
|
+
if (rows.length !== proof.liveCount) {
|
|
915
|
+
throw new StreamCompletionError("snapshot_conflict");
|
|
916
|
+
}
|
|
917
|
+
const digest = liveSnapshotDigest({ ...request, stream: proof.stream }, primaryKey, rows.map((row) => ({
|
|
918
|
+
recordKey: row.record_key,
|
|
919
|
+
dataJson: row.data,
|
|
920
|
+
})));
|
|
921
|
+
if (digest !== proof.snapshotSha256) {
|
|
922
|
+
throw new StreamCompletionError("snapshot_conflict");
|
|
923
|
+
}
|
|
924
|
+
const prior = db
|
|
925
|
+
.prepare("SELECT * FROM pdpp_stream_completion WHERE instance = ? AND stream = ?")
|
|
926
|
+
.get(request.instance, proof.stream);
|
|
927
|
+
if (!prior) {
|
|
928
|
+
writes.push({ stream: proof.stream, kind: "insert" });
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
// A receipt is deleted by every write that could invalidate it, so a
|
|
932
|
+
// stored one that disagrees with the live data was tampered with.
|
|
933
|
+
const native = prior.snapshot_sha256 !== null;
|
|
934
|
+
if (!receiptInvariantsHold(prior, {
|
|
935
|
+
method: request.method,
|
|
936
|
+
generation: request.generation,
|
|
937
|
+
liveCount: rows.length,
|
|
938
|
+
clock,
|
|
939
|
+
lastChange: lastChangeSeq(request.instance, proof.stream),
|
|
940
|
+
digest: () => digest,
|
|
941
|
+
})) {
|
|
942
|
+
throw new StreamCompletionError("invalid_prior_receipt");
|
|
943
|
+
}
|
|
944
|
+
const priorAt = Date.parse(prior.source_collected_at);
|
|
945
|
+
if (requestedAt < priorAt) {
|
|
946
|
+
throw new StreamCompletionError("time_conflict");
|
|
947
|
+
}
|
|
948
|
+
if (requestedAt === priorAt) {
|
|
949
|
+
// Same instant: an exact replay keeps its bytes. A digest-less
|
|
950
|
+
// receipt cannot be relabeled as native proof at its own time.
|
|
951
|
+
if (!native)
|
|
952
|
+
throw new StreamCompletionError("time_conflict");
|
|
953
|
+
writes.push({ stream: proof.stream, kind: "keep" });
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
writes.push({ stream: proof.stream, kind: "update" });
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const [index, write] of writes.entries()) {
|
|
960
|
+
const proof = request.streams[index];
|
|
961
|
+
if (write.kind === "insert") {
|
|
962
|
+
db.prepare(`INSERT INTO pdpp_stream_completion
|
|
963
|
+
(instance, stream, method, generation, source_collected_at, write_seq, live_count, snapshot_sha256, collection_time_basis)
|
|
964
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(request.instance, proof.stream, request.method, request.generation, request.sourceCollectedAt, clock, proof.liveCount, proof.snapshotSha256, request.collectionTimeBasis);
|
|
965
|
+
}
|
|
966
|
+
else if (write.kind === "update") {
|
|
967
|
+
db.prepare(`UPDATE pdpp_stream_completion
|
|
968
|
+
SET source_collected_at = ?, write_seq = ?, snapshot_sha256 = ?, collection_time_basis = ?
|
|
969
|
+
WHERE instance = ? AND stream = ?`).run(request.sourceCollectedAt, clock, proof.snapshotSha256, request.collectionTimeBasis, request.instance, proof.stream);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
receipts: request.streams.map((proof) => {
|
|
974
|
+
const row = db
|
|
975
|
+
.prepare("SELECT * FROM pdpp_stream_completion WHERE instance = ? AND stream = ?")
|
|
976
|
+
.get(request.instance, proof.stream);
|
|
977
|
+
return {
|
|
978
|
+
instance: row.instance,
|
|
979
|
+
stream: row.stream,
|
|
980
|
+
method: row.method,
|
|
981
|
+
generation: row.generation,
|
|
982
|
+
sourceCollectedAt: row.source_collected_at,
|
|
983
|
+
writeSeq: row.write_seq,
|
|
984
|
+
liveCount: row.live_count,
|
|
985
|
+
snapshotSha256: row.snapshot_sha256,
|
|
986
|
+
collectionTimeBasis: "host_run_finished",
|
|
987
|
+
};
|
|
988
|
+
}),
|
|
989
|
+
};
|
|
990
|
+
})
|
|
991
|
+
.immediate();
|
|
992
|
+
}
|
|
993
|
+
function listArrivalOrder(instance, stream) {
|
|
994
|
+
const rows = db
|
|
995
|
+
.prepare(`SELECT a.record_key FROM pdpp_record_arrival a
|
|
996
|
+
JOIN pdpp_instance_binding b
|
|
997
|
+
ON b.instance = a.instance AND b.generation = a.generation
|
|
998
|
+
WHERE a.instance = ? AND a.stream = ?
|
|
999
|
+
ORDER BY a.arrival`)
|
|
1000
|
+
.all(instance, stream);
|
|
1001
|
+
return rows.map((row) => row.record_key);
|
|
1002
|
+
}
|
|
1003
|
+
function getRecord(instance, stream, recordKey) {
|
|
1004
|
+
const row = getCurrentStmt.get(instance, stream, recordKey);
|
|
1005
|
+
if (!row || row.deleted)
|
|
1006
|
+
return undefined;
|
|
1007
|
+
return toRow(row);
|
|
1008
|
+
}
|
|
1009
|
+
// P10c: a token whose horizon is below the reset_clock of any instance it
|
|
1010
|
+
// reads spans a reset. `null` means the token predates horizons, so any
|
|
1011
|
+
// reset of a read instance expires it.
|
|
1012
|
+
function assertNotResetSince(instanceIds, horizons) {
|
|
1013
|
+
if (instanceIds.length === 0)
|
|
1014
|
+
return;
|
|
1015
|
+
const resetClocks = db
|
|
1016
|
+
.prepare(`SELECT reset_clock FROM pdpp_instance_binding
|
|
1017
|
+
WHERE instance IN (${instanceIds.map(() => "?").join(",")}) AND reset_clock > 0`)
|
|
1018
|
+
.all(...instanceIds);
|
|
1019
|
+
if (resetClocks.some(({ reset_clock }) => horizons.some((h) => h === null || h < reset_clock))) {
|
|
1020
|
+
throw new CursorExpiredError();
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
function cursorEpoch() {
|
|
1024
|
+
const row = db
|
|
1025
|
+
.prepare("SELECT epoch FROM pdpp_store_metadata WHERE id = 1")
|
|
1026
|
+
.get();
|
|
1027
|
+
if (!row)
|
|
1028
|
+
throw new CursorExpiredError();
|
|
1029
|
+
return row.epoch;
|
|
1030
|
+
}
|
|
1031
|
+
function assertCursorScope(payload, stream) {
|
|
1032
|
+
if (payload.stream !== stream) {
|
|
1033
|
+
throw new CursorExpiredError();
|
|
1034
|
+
}
|
|
1035
|
+
if (payload.epoch !== cursorEpoch()) {
|
|
1036
|
+
throw new CursorExpiredError();
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
function listRecords(stream, options) {
|
|
1040
|
+
let startAfter = null;
|
|
1041
|
+
let horizon;
|
|
1042
|
+
if (options.cursor) {
|
|
1043
|
+
const payload = decodeCursor(options.cursor);
|
|
1044
|
+
if (payload.kind !== "list")
|
|
1045
|
+
throw new InvalidCursorError();
|
|
1046
|
+
assertCursorScope(payload, stream);
|
|
1047
|
+
if (payload.order !== options.order)
|
|
1048
|
+
throw new InvalidCursorError();
|
|
1049
|
+
const cursorHorizon = payload.horizon === undefined ? null : parseHorizon(payload.horizon);
|
|
1050
|
+
assertNotResetSince(options.instanceIds, [cursorHorizon]);
|
|
1051
|
+
// A legacy cursor that survives the fence read no reset instance; it
|
|
1052
|
+
// is anchored at the current clock from here on.
|
|
1053
|
+
horizon = cursorHorizon ?? readWriteClock();
|
|
1054
|
+
startAfter = { val: payload.sortValue ?? "", key: payload.recordKey };
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
horizon = readWriteClock();
|
|
1058
|
+
}
|
|
1059
|
+
const placeholders = options.instanceIds.map(() => "?").join(",");
|
|
1060
|
+
const dir = options.order === "asc" ? "ASC" : "DESC";
|
|
1061
|
+
const cmp = options.order === "asc" ? ">" : "<";
|
|
1062
|
+
let sql = `
|
|
1063
|
+
SELECT * FROM pdpp_records
|
|
1064
|
+
WHERE stream = ? AND deleted = 0 AND instance IN (${placeholders})
|
|
1065
|
+
`;
|
|
1066
|
+
const params = [stream, ...options.instanceIds];
|
|
1067
|
+
if (startAfter) {
|
|
1068
|
+
sql += ` AND (emitted_at ${cmp} ? OR (emitted_at = ? AND record_key ${cmp} ?))`;
|
|
1069
|
+
params.push(startAfter.val, startAfter.val, startAfter.key);
|
|
1070
|
+
}
|
|
1071
|
+
sql += ` ORDER BY emitted_at ${dir}, record_key ${dir} LIMIT ?`;
|
|
1072
|
+
params.push(options.limit + 1);
|
|
1073
|
+
const rows = db.prepare(sql).all(...params);
|
|
1074
|
+
const hasMore = rows.length > options.limit;
|
|
1075
|
+
const page = rows.slice(0, options.limit);
|
|
1076
|
+
const last = page[page.length - 1];
|
|
1077
|
+
return {
|
|
1078
|
+
data: page.map((row) => {
|
|
1079
|
+
const stored = toRow(row);
|
|
1080
|
+
return { ...stored, data: projectFields(stored.data, options.fields) };
|
|
1081
|
+
}),
|
|
1082
|
+
hasMore,
|
|
1083
|
+
nextCursor: hasMore && last
|
|
1084
|
+
? encodeCursor({
|
|
1085
|
+
kind: "list",
|
|
1086
|
+
stream,
|
|
1087
|
+
epoch: cursorEpoch(),
|
|
1088
|
+
order: options.order,
|
|
1089
|
+
sortValue: last.emitted_at,
|
|
1090
|
+
recordKey: last.record_key,
|
|
1091
|
+
horizon: String(horizon),
|
|
1092
|
+
})
|
|
1093
|
+
: undefined,
|
|
1094
|
+
horizon: String(horizon),
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
function deleteRecord(instance, stream, recordKey, deletedAt, streamSemantics) {
|
|
1098
|
+
if (streamSemantics === "append_only")
|
|
1099
|
+
return false;
|
|
1100
|
+
let result = false;
|
|
1101
|
+
const run = db.transaction(() => {
|
|
1102
|
+
const existing = getCurrentStmt.get(instance, stream, recordKey);
|
|
1103
|
+
if (!existing || existing.deleted)
|
|
1104
|
+
return;
|
|
1105
|
+
const version = existing.version + 1;
|
|
1106
|
+
const writtenAt = nextWriteSeq.get();
|
|
1107
|
+
dropCompletionStmt.run(instance, stream);
|
|
1108
|
+
upsertCurrentStmt.run({
|
|
1109
|
+
instance,
|
|
1110
|
+
stream,
|
|
1111
|
+
record_key: recordKey,
|
|
1112
|
+
data: null,
|
|
1113
|
+
version,
|
|
1114
|
+
emitted_at: deletedAt,
|
|
1115
|
+
deleted: 1,
|
|
1116
|
+
deleted_at: deletedAt,
|
|
1117
|
+
blob_id: null,
|
|
1118
|
+
});
|
|
1119
|
+
insertHistoryStmt.run({
|
|
1120
|
+
instance,
|
|
1121
|
+
stream,
|
|
1122
|
+
record_key: recordKey,
|
|
1123
|
+
version,
|
|
1124
|
+
data: null,
|
|
1125
|
+
emitted_at: deletedAt,
|
|
1126
|
+
deleted: 1,
|
|
1127
|
+
deleted_at: deletedAt,
|
|
1128
|
+
written_at: writtenAt.value,
|
|
1129
|
+
});
|
|
1130
|
+
forgetArrivalStmt.run(instance, stream, recordKey);
|
|
1131
|
+
result = true;
|
|
1132
|
+
});
|
|
1133
|
+
run();
|
|
1134
|
+
return result;
|
|
1135
|
+
}
|
|
1136
|
+
function changesSince(stream, options) {
|
|
1137
|
+
let horizon;
|
|
1138
|
+
let sinceHorizon;
|
|
1139
|
+
let offset = 0;
|
|
1140
|
+
if (options.cursor) {
|
|
1141
|
+
const payload = decodeCursor(options.cursor);
|
|
1142
|
+
if (payload.kind !== "changes_since")
|
|
1143
|
+
throw new InvalidCursorError();
|
|
1144
|
+
assertCursorScope(payload, stream);
|
|
1145
|
+
horizon = parseHorizon(payload.horizon);
|
|
1146
|
+
sinceHorizon =
|
|
1147
|
+
payload.sinceHorizon !== null
|
|
1148
|
+
? parseHorizon(payload.sinceHorizon)
|
|
1149
|
+
: null;
|
|
1150
|
+
offset = parseOffset(payload.offset);
|
|
1151
|
+
}
|
|
1152
|
+
else if (options.changesSince) {
|
|
1153
|
+
let payload;
|
|
1154
|
+
try {
|
|
1155
|
+
payload = decodeCursor(options.changesSince);
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
if (err instanceof InvalidCursorSyntaxError)
|
|
1159
|
+
throw new CursorExpiredError();
|
|
1160
|
+
throw err;
|
|
1161
|
+
}
|
|
1162
|
+
if (payload.kind !== "changes_since")
|
|
1163
|
+
throw new InvalidCursorError();
|
|
1164
|
+
assertCursorScope(payload, stream);
|
|
1165
|
+
sinceHorizon = parseHorizon(payload.horizon);
|
|
1166
|
+
horizon = nextWriteSeq.get().value;
|
|
1167
|
+
}
|
|
1168
|
+
else {
|
|
1169
|
+
sinceHorizon = null;
|
|
1170
|
+
horizon = nextWriteSeq.get().value;
|
|
1171
|
+
}
|
|
1172
|
+
assertNotResetSince(options.instanceIds, [
|
|
1173
|
+
horizon,
|
|
1174
|
+
sinceHorizon ?? horizon,
|
|
1175
|
+
]);
|
|
1176
|
+
const placeholders = options.instanceIds.map(() => "?").join(",");
|
|
1177
|
+
// Latest history row per key, as of the horizon.
|
|
1178
|
+
const latestRows = db
|
|
1179
|
+
.prepare(`
|
|
1180
|
+
SELECT c.* FROM pdpp_record_changes c
|
|
1181
|
+
WHERE c.stream = ? AND c.instance IN (${placeholders}) AND c.written_at <= ?
|
|
1182
|
+
AND c.version = (
|
|
1183
|
+
SELECT MAX(c2.version) FROM pdpp_record_changes c2
|
|
1184
|
+
WHERE c2.stream = c.stream AND c2.instance = c.instance
|
|
1185
|
+
AND c2.record_key = c.record_key AND c2.written_at <= ?
|
|
1186
|
+
)
|
|
1187
|
+
`)
|
|
1188
|
+
.all(stream, ...options.instanceIds, horizon, horizon);
|
|
1189
|
+
const changed = [];
|
|
1190
|
+
for (const entry of latestRows) {
|
|
1191
|
+
if (sinceHorizon !== null) {
|
|
1192
|
+
const wroteSincePrior = db
|
|
1193
|
+
.prepare(`SELECT 1 FROM pdpp_record_changes
|
|
1194
|
+
WHERE stream = ? AND instance = ? AND record_key = ?
|
|
1195
|
+
AND written_at > ? AND written_at <= ? LIMIT 1`)
|
|
1196
|
+
.get(entry.stream, entry.instance, entry.record_key, sinceHorizon, horizon);
|
|
1197
|
+
if (!wroteSincePrior)
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
if (entry.deleted) {
|
|
1201
|
+
changed.push({
|
|
1202
|
+
instance: entry.instance,
|
|
1203
|
+
stream: entry.stream,
|
|
1204
|
+
recordKey: entry.record_key,
|
|
1205
|
+
version: entry.version,
|
|
1206
|
+
deletedAt: entry.deleted_at ?? entry.emitted_at,
|
|
1207
|
+
emittedAt: entry.emitted_at,
|
|
1208
|
+
deleted: true,
|
|
1209
|
+
});
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
const currentData = entry.data ? JSON.parse(entry.data) : {};
|
|
1213
|
+
const projectedNow = projectFields(currentData, options.fields);
|
|
1214
|
+
if (sinceHorizon !== null && options.fields) {
|
|
1215
|
+
const priorRow = db
|
|
1216
|
+
.prepare(`SELECT * FROM pdpp_record_changes
|
|
1217
|
+
WHERE stream = ? AND instance = ? AND record_key = ? AND written_at <= ?
|
|
1218
|
+
ORDER BY version DESC LIMIT 1`)
|
|
1219
|
+
.get(entry.stream, entry.instance, entry.record_key, sinceHorizon);
|
|
1220
|
+
if (priorRow && !priorRow.deleted) {
|
|
1221
|
+
const priorData = priorRow.data ? JSON.parse(priorRow.data) : {};
|
|
1222
|
+
const priorProjected = projectFields(priorData, options.fields);
|
|
1223
|
+
if (JSON.stringify(priorProjected) === JSON.stringify(projectedNow)) {
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// Return the RAW (unprojected) data here, not `projectedNow`.
|
|
1229
|
+
// `projectedNow`/`priorProjected` above exist only to decide
|
|
1230
|
+
// eligibility (did the grant-authorized projection change) without
|
|
1231
|
+
// leaking a hidden-field change. A caller enforcing time_constraint
|
|
1232
|
+
// against `data` needs the real field value; projecting here would
|
|
1233
|
+
// silently break that filter for any grant whose fields don't happen
|
|
1234
|
+
// to include the time_constraint field. Response field projection is
|
|
1235
|
+
// the caller's job, applied after any filtering on real values.
|
|
1236
|
+
changed.push({
|
|
1237
|
+
instance: entry.instance,
|
|
1238
|
+
stream: entry.stream,
|
|
1239
|
+
recordKey: entry.record_key,
|
|
1240
|
+
data: currentData,
|
|
1241
|
+
version: entry.version,
|
|
1242
|
+
emittedAt: entry.emitted_at,
|
|
1243
|
+
deleted: false,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
changed.sort((a, b) => a.recordKey.localeCompare(b.recordKey));
|
|
1247
|
+
const page = changed.slice(offset, offset + options.limit);
|
|
1248
|
+
const hasMore = offset + options.limit < changed.length;
|
|
1249
|
+
return {
|
|
1250
|
+
data: page,
|
|
1251
|
+
hasMore,
|
|
1252
|
+
nextCursor: hasMore
|
|
1253
|
+
? encodeCursor({
|
|
1254
|
+
kind: "changes_since",
|
|
1255
|
+
stream,
|
|
1256
|
+
epoch: cursorEpoch(),
|
|
1257
|
+
horizon: String(horizon),
|
|
1258
|
+
sinceHorizon: sinceHorizon !== null ? String(sinceHorizon) : null,
|
|
1259
|
+
offset: offset + options.limit,
|
|
1260
|
+
})
|
|
1261
|
+
: undefined,
|
|
1262
|
+
nextChangesSince: hasMore
|
|
1263
|
+
? undefined
|
|
1264
|
+
: encodeCursor({
|
|
1265
|
+
kind: "changes_since",
|
|
1266
|
+
stream,
|
|
1267
|
+
epoch: cursorEpoch(),
|
|
1268
|
+
horizon: String(horizon),
|
|
1269
|
+
sinceHorizon: null,
|
|
1270
|
+
offset: 0,
|
|
1271
|
+
}),
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
function listStreams(instanceIds) {
|
|
1275
|
+
const placeholders = instanceIds.map(() => "?").join(",");
|
|
1276
|
+
const rows = db
|
|
1277
|
+
.prepare(`SELECT stream, COUNT(*) as count, MAX(emitted_at) as last_updated
|
|
1278
|
+
FROM pdpp_records WHERE deleted = 0 AND instance IN (${placeholders})
|
|
1279
|
+
GROUP BY stream`)
|
|
1280
|
+
.all(...instanceIds);
|
|
1281
|
+
return rows.map((r) => ({
|
|
1282
|
+
stream: r.stream,
|
|
1283
|
+
recordCount: r.count,
|
|
1284
|
+
lastUpdated: r.last_updated,
|
|
1285
|
+
}));
|
|
1286
|
+
}
|
|
1287
|
+
function getBlobMeta(blobId) {
|
|
1288
|
+
const row = getBlobStmt.get(blobId);
|
|
1289
|
+
if (!row)
|
|
1290
|
+
return undefined;
|
|
1291
|
+
return {
|
|
1292
|
+
blobId: row.blob_id,
|
|
1293
|
+
mimeType: row.mime_type,
|
|
1294
|
+
sizeBytes: row.size_bytes,
|
|
1295
|
+
sha256: row.sha256,
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/** True only when a stored bytes row actually hash/size-matches `meta`. */
|
|
1299
|
+
function storedBytesVerify(meta) {
|
|
1300
|
+
const row = getBlobBytesStmt.get(meta.blobId);
|
|
1301
|
+
if (!row)
|
|
1302
|
+
return false;
|
|
1303
|
+
const stored = new Uint8Array(row.bytes);
|
|
1304
|
+
if (stored.byteLength !== meta.sizeBytes)
|
|
1305
|
+
return false;
|
|
1306
|
+
return createHash("sha256").update(stored).digest("hex") === meta.sha256;
|
|
1307
|
+
}
|
|
1308
|
+
function storeBlobBytes(bytes, mimeType) {
|
|
1309
|
+
const blobId = blobIdForBytes(bytes);
|
|
1310
|
+
const sha256 = blobId.slice("sha256:".length);
|
|
1311
|
+
const candidate = {
|
|
1312
|
+
blobId,
|
|
1313
|
+
mimeType,
|
|
1314
|
+
sizeBytes: bytes.byteLength,
|
|
1315
|
+
sha256,
|
|
1316
|
+
};
|
|
1317
|
+
// The existing-row read and the write both happen inside one
|
|
1318
|
+
// transaction, not a check-then-upsert outside it -- otherwise two
|
|
1319
|
+
// concurrent writers for the same content could both see "no existing
|
|
1320
|
+
// row" and race to insert, or one could complete a metadata-only row
|
|
1321
|
+
// while another concurrently overwrites it.
|
|
1322
|
+
const run = db.transaction(() => {
|
|
1323
|
+
const existing = getBlobMeta(blobId);
|
|
1324
|
+
if (existing) {
|
|
1325
|
+
if (existing.mimeType !== mimeType ||
|
|
1326
|
+
existing.sha256 !== sha256 ||
|
|
1327
|
+
existing.sizeBytes !== bytes.byteLength) {
|
|
1328
|
+
throw new BlobConflictError(blobId, existing);
|
|
1329
|
+
}
|
|
1330
|
+
const bytesRow = getBlobBytesStmt.get(blobId);
|
|
1331
|
+
if (!bytesRow) {
|
|
1332
|
+
// Metadata-only row (legacy/test fixture via putBlobMeta, or a
|
|
1333
|
+
// prior write that never got this far): complete it with real
|
|
1334
|
+
// bytes now instead of reporting a false "already stored" no-op.
|
|
1335
|
+
insertBlobBytesStmt.run({
|
|
1336
|
+
blob_id: existing.blobId,
|
|
1337
|
+
bytes: Buffer.from(bytes),
|
|
1338
|
+
});
|
|
1339
|
+
return existing;
|
|
1340
|
+
}
|
|
1341
|
+
if (!storedBytesVerify(existing)) {
|
|
1342
|
+
// Bytes present but corrupt relative to their own metadata.
|
|
1343
|
+
// Refuse rather than report success over broken content, and
|
|
1344
|
+
// rather than silently repair -- the row stays exactly as it was
|
|
1345
|
+
// (the transaction makes no writes on this branch).
|
|
1346
|
+
throw new BlobConflictError(blobId, existing);
|
|
1347
|
+
}
|
|
1348
|
+
// Verified idempotent no-op: content and mimeType both match.
|
|
1349
|
+
return existing;
|
|
1350
|
+
}
|
|
1351
|
+
putBlobStmt.run({
|
|
1352
|
+
blob_id: candidate.blobId,
|
|
1353
|
+
mime_type: candidate.mimeType,
|
|
1354
|
+
size_bytes: candidate.sizeBytes,
|
|
1355
|
+
sha256: candidate.sha256,
|
|
1356
|
+
});
|
|
1357
|
+
insertBlobBytesStmt.run({
|
|
1358
|
+
blob_id: candidate.blobId,
|
|
1359
|
+
bytes: Buffer.from(bytes),
|
|
1360
|
+
});
|
|
1361
|
+
return candidate;
|
|
1362
|
+
});
|
|
1363
|
+
return run();
|
|
1364
|
+
}
|
|
1365
|
+
function storeBlobBytesForInstance(input) {
|
|
1366
|
+
return db.transaction(() => {
|
|
1367
|
+
const binding = ensureBinding(db, input.instance);
|
|
1368
|
+
if (binding.deleted_at !== null) {
|
|
1369
|
+
throw new PdppBindingError("connection_deleted");
|
|
1370
|
+
}
|
|
1371
|
+
if (binding.source_id === null) {
|
|
1372
|
+
throw new PdppBindingError("connection_required");
|
|
1373
|
+
}
|
|
1374
|
+
if (binding.generation !== input.generation) {
|
|
1375
|
+
throw new PdppBindingError("binding_generation_mismatch");
|
|
1376
|
+
}
|
|
1377
|
+
if (binding.method !== input.method) {
|
|
1378
|
+
throw new PdppBindingError("instance_bound_to_other_method");
|
|
1379
|
+
}
|
|
1380
|
+
const meta = storeBlobBytes(input.bytes, input.mimeType);
|
|
1381
|
+
db.prepare(`INSERT INTO pdpp_blob_claims (blob_id, instance, generation)
|
|
1382
|
+
VALUES (?, ?, ?)
|
|
1383
|
+
ON CONFLICT (blob_id, instance) DO UPDATE SET generation = excluded.generation`).run(meta.blobId, input.instance, input.generation);
|
|
1384
|
+
return meta;
|
|
1385
|
+
})();
|
|
1386
|
+
}
|
|
1387
|
+
function getBlobBytes(blobId) {
|
|
1388
|
+
const meta = getBlobMeta(blobId);
|
|
1389
|
+
if (!meta)
|
|
1390
|
+
return undefined;
|
|
1391
|
+
const row = getBlobBytesStmt.get(blobId);
|
|
1392
|
+
if (!row)
|
|
1393
|
+
return undefined;
|
|
1394
|
+
const bytes = new Uint8Array(row.bytes);
|
|
1395
|
+
if (bytes.byteLength !== meta.sizeBytes)
|
|
1396
|
+
return undefined;
|
|
1397
|
+
const actualHash = createHash("sha256").update(bytes).digest("hex");
|
|
1398
|
+
if (actualHash !== meta.sha256)
|
|
1399
|
+
return undefined;
|
|
1400
|
+
return bytes;
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
listConnections,
|
|
1404
|
+
registerConnection,
|
|
1405
|
+
deleteConnection,
|
|
1406
|
+
ingestBatch,
|
|
1407
|
+
getInstanceBinding,
|
|
1408
|
+
readSnapshot: (read) => db.transaction(read).deferred(),
|
|
1409
|
+
resetInstanceBinding,
|
|
1410
|
+
storeBlobBytesForInstance,
|
|
1411
|
+
replaceStream,
|
|
1412
|
+
listStreamCompletions,
|
|
1413
|
+
completeStreams,
|
|
1414
|
+
listArrivalOrder,
|
|
1415
|
+
getRecord,
|
|
1416
|
+
listRecords,
|
|
1417
|
+
deleteRecord,
|
|
1418
|
+
changesSince,
|
|
1419
|
+
listStreams,
|
|
1420
|
+
putBlobMeta: (meta) => putBlobStmt.run({
|
|
1421
|
+
blob_id: meta.blobId,
|
|
1422
|
+
mime_type: meta.mimeType,
|
|
1423
|
+
size_bytes: meta.sizeBytes,
|
|
1424
|
+
sha256: meta.sha256,
|
|
1425
|
+
}),
|
|
1426
|
+
getBlobMeta,
|
|
1427
|
+
storeBlobBytes,
|
|
1428
|
+
getBlobBytes,
|
|
1429
|
+
findBlobReferences: (blobId) => {
|
|
1430
|
+
const rows = findBlobRefsStmt.all(blobId);
|
|
1431
|
+
return rows.map((row) => ({
|
|
1432
|
+
instance: row.instance,
|
|
1433
|
+
stream: row.stream,
|
|
1434
|
+
recordKey: row.record_key,
|
|
1435
|
+
}));
|
|
1436
|
+
},
|
|
1437
|
+
close: () => db.close(),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
//# sourceMappingURL=pdpp-records-sqlite-store.js.map
|