@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.
Files changed (167) hide show
  1. package/dist/__fixtures__/bound-record-store.d.ts +19 -0
  2. package/dist/__fixtures__/bound-record-store.d.ts.map +1 -0
  3. package/dist/__fixtures__/bound-record-store.js +60 -0
  4. package/dist/__fixtures__/bound-record-store.js.map +1 -0
  5. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts +41 -0
  6. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.d.ts.map +1 -0
  7. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js +34 -0
  8. package/dist/__fixtures__/cg-pdpp-client/pdpp-authorization-strategy.js.map +1 -0
  9. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts +112 -0
  10. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.d.ts.map +1 -0
  11. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js +226 -0
  12. package/dist/__fixtures__/cg-pdpp-client/pdpp-context-client.js.map +1 -0
  13. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts +135 -0
  14. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.d.ts.map +1 -0
  15. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js +21 -0
  16. package/dist/__fixtures__/cg-pdpp-client/pdpp-types.js.map +1 -0
  17. package/dist/api-auth.d.ts +18 -2
  18. package/dist/api-auth.d.ts.map +1 -1
  19. package/dist/api-auth.js +114 -22
  20. package/dist/api-auth.js.map +1 -1
  21. package/dist/app.d.ts +60 -1
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +139 -1
  24. package/dist/app.js.map +1 -1
  25. package/dist/bootstrap.d.ts +10 -0
  26. package/dist/bootstrap.d.ts.map +1 -1
  27. package/dist/bootstrap.js +359 -8
  28. package/dist/bootstrap.js.map +1 -1
  29. package/dist/enclave-main.d.ts +4 -0
  30. package/dist/enclave-main.d.ts.map +1 -1
  31. package/dist/enclave-main.js +152 -1
  32. package/dist/enclave-main.js.map +1 -1
  33. package/dist/middleware/body-limit.d.ts +1 -1
  34. package/dist/middleware/body-limit.d.ts.map +1 -1
  35. package/dist/middleware/body-limit.js +8 -3
  36. package/dist/middleware/body-limit.js.map +1 -1
  37. package/dist/middleware/cors.d.ts.map +1 -1
  38. package/dist/middleware/cors.js +1 -0
  39. package/dist/middleware/cors.js.map +1 -1
  40. package/dist/package.json +9 -1
  41. package/dist/pdpp/bootstrap.d.ts +72 -0
  42. package/dist/pdpp/bootstrap.d.ts.map +1 -0
  43. package/dist/pdpp/bootstrap.js +270 -0
  44. package/dist/pdpp/bootstrap.js.map +1 -0
  45. package/dist/pdpp/chain-enforcement.d.ts +95 -0
  46. package/dist/pdpp/chain-enforcement.d.ts.map +1 -0
  47. package/dist/pdpp/chain-enforcement.js +145 -0
  48. package/dist/pdpp/chain-enforcement.js.map +1 -0
  49. package/dist/pdpp/client-document-fetch.d.ts +63 -0
  50. package/dist/pdpp/client-document-fetch.d.ts.map +1 -0
  51. package/dist/pdpp/client-document-fetch.js +254 -0
  52. package/dist/pdpp/client-document-fetch.js.map +1 -0
  53. package/dist/pdpp/declaration-registry.d.ts +83 -0
  54. package/dist/pdpp/declaration-registry.d.ts.map +1 -0
  55. package/dist/pdpp/declaration-registry.js +251 -0
  56. package/dist/pdpp/declaration-registry.js.map +1 -0
  57. package/dist/pdpp/deployment.d.ts +70 -0
  58. package/dist/pdpp/deployment.d.ts.map +1 -0
  59. package/dist/pdpp/deployment.js +104 -0
  60. package/dist/pdpp/deployment.js.map +1 -0
  61. package/dist/pdpp/record-schema.d.ts +2 -0
  62. package/dist/pdpp/record-schema.d.ts.map +1 -0
  63. package/dist/pdpp/record-schema.js +2 -0
  64. package/dist/pdpp/record-schema.js.map +1 -0
  65. package/dist/pdpp/records-bootstrap.d.ts +111 -0
  66. package/dist/pdpp/records-bootstrap.d.ts.map +1 -0
  67. package/dist/pdpp/records-bootstrap.js +353 -0
  68. package/dist/pdpp/records-bootstrap.js.map +1 -0
  69. package/dist/pdpp/source-id-compat.d.ts +5 -0
  70. package/dist/pdpp/source-id-compat.d.ts.map +1 -0
  71. package/dist/pdpp/source-id-compat.js +16 -0
  72. package/dist/pdpp/source-id-compat.js.map +1 -0
  73. package/dist/pdpp/test-connections.d.ts +6 -0
  74. package/dist/pdpp/test-connections.d.ts.map +1 -0
  75. package/dist/pdpp/test-connections.js +37 -0
  76. package/dist/pdpp/test-connections.js.map +1 -0
  77. package/dist/routes/auth-device.d.ts.map +1 -1
  78. package/dist/routes/auth-device.js +3 -0
  79. package/dist/routes/auth-device.js.map +1 -1
  80. package/dist/routes/chunked-scope-import.d.ts +41 -0
  81. package/dist/routes/chunked-scope-import.d.ts.map +1 -0
  82. package/dist/routes/chunked-scope-import.js +901 -0
  83. package/dist/routes/chunked-scope-import.js.map +1 -0
  84. package/dist/routes/data.d.ts +23 -1
  85. package/dist/routes/data.d.ts.map +1 -1
  86. package/dist/routes/data.js +112 -1
  87. package/dist/routes/data.js.map +1 -1
  88. package/dist/routes/grants.d.ts.map +1 -1
  89. package/dist/routes/grants.js +2 -0
  90. package/dist/routes/grants.js.map +1 -1
  91. package/dist/routes/mcp.d.ts.map +1 -1
  92. package/dist/routes/mcp.js +3 -0
  93. package/dist/routes/mcp.js.map +1 -1
  94. package/dist/routes/pdpp-auth.d.ts +157 -0
  95. package/dist/routes/pdpp-auth.d.ts.map +1 -0
  96. package/dist/routes/pdpp-auth.js +953 -0
  97. package/dist/routes/pdpp-auth.js.map +1 -0
  98. package/dist/routes/pdpp-blobs.d.ts +26 -0
  99. package/dist/routes/pdpp-blobs.d.ts.map +1 -0
  100. package/dist/routes/pdpp-blobs.js +179 -0
  101. package/dist/routes/pdpp-blobs.js.map +1 -0
  102. package/dist/routes/pdpp-connections.d.ts +28 -0
  103. package/dist/routes/pdpp-connections.d.ts.map +1 -0
  104. package/dist/routes/pdpp-connections.js +144 -0
  105. package/dist/routes/pdpp-connections.js.map +1 -0
  106. package/dist/routes/pdpp-declarations.d.ts +58 -0
  107. package/dist/routes/pdpp-declarations.d.ts.map +1 -0
  108. package/dist/routes/pdpp-declarations.js +94 -0
  109. package/dist/routes/pdpp-declarations.js.map +1 -0
  110. package/dist/routes/pdpp-instance-bindings.d.ts +26 -0
  111. package/dist/routes/pdpp-instance-bindings.d.ts.map +1 -0
  112. package/dist/routes/pdpp-instance-bindings.js +124 -0
  113. package/dist/routes/pdpp-instance-bindings.js.map +1 -0
  114. package/dist/routes/pdpp-records.d.ts +158 -0
  115. package/dist/routes/pdpp-records.d.ts.map +1 -0
  116. package/dist/routes/pdpp-records.js +1537 -0
  117. package/dist/routes/pdpp-records.js.map +1 -0
  118. package/dist/routes/pdpp-well-known.d.ts +13 -0
  119. package/dist/routes/pdpp-well-known.d.ts.map +1 -0
  120. package/dist/routes/pdpp-well-known.js +20 -0
  121. package/dist/routes/pdpp-well-known.js.map +1 -0
  122. package/dist/storage/declaration-witness.d.ts +56 -0
  123. package/dist/storage/declaration-witness.d.ts.map +1 -0
  124. package/dist/storage/declaration-witness.js +273 -0
  125. package/dist/storage/declaration-witness.js.map +1 -0
  126. package/dist/storage/hierarchy.d.ts +25 -1
  127. package/dist/storage/hierarchy.d.ts.map +1 -1
  128. package/dist/storage/hierarchy.js +210 -16
  129. package/dist/storage/hierarchy.js.map +1 -1
  130. package/dist/storage/import-operations.d.ts +11 -0
  131. package/dist/storage/import-operations.d.ts.map +1 -0
  132. package/dist/storage/import-operations.js +261 -0
  133. package/dist/storage/import-operations.js.map +1 -0
  134. package/dist/storage/index-manager.d.ts +24 -2
  135. package/dist/storage/index-manager.d.ts.map +1 -1
  136. package/dist/storage/index-manager.js +279 -30
  137. package/dist/storage/index-manager.js.map +1 -1
  138. package/dist/storage/index-schema.d.ts +1 -1
  139. package/dist/storage/index-schema.d.ts.map +1 -1
  140. package/dist/storage/index-schema.js +189 -8
  141. package/dist/storage/index-schema.js.map +1 -1
  142. package/dist/storage/legacy-scope-authority.d.ts +14 -0
  143. package/dist/storage/legacy-scope-authority.d.ts.map +1 -0
  144. package/dist/storage/legacy-scope-authority.js +298 -0
  145. package/dist/storage/legacy-scope-authority.js.map +1 -0
  146. package/dist/storage/node-data-storage.d.ts +6 -0
  147. package/dist/storage/node-data-storage.d.ts.map +1 -1
  148. package/dist/storage/node-data-storage.js +161 -2
  149. package/dist/storage/node-data-storage.js.map +1 -1
  150. package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts +17 -0
  151. package/dist/storage/pdpp-grant-binding-sqlite-store.d.ts.map +1 -0
  152. package/dist/storage/pdpp-grant-binding-sqlite-store.js +159 -0
  153. package/dist/storage/pdpp-grant-binding-sqlite-store.js.map +1 -0
  154. package/dist/storage/pdpp-records-sqlite-store.d.ts +112 -0
  155. package/dist/storage/pdpp-records-sqlite-store.d.ts.map +1 -0
  156. package/dist/storage/pdpp-records-sqlite-store.js +1440 -0
  157. package/dist/storage/pdpp-records-sqlite-store.js.map +1 -0
  158. package/dist/storage/pdpp-stream-completion.d.ts +5 -0
  159. package/dist/storage/pdpp-stream-completion.d.ts.map +1 -0
  160. package/dist/storage/pdpp-stream-completion.js +10 -0
  161. package/dist/storage/pdpp-stream-completion.js.map +1 -0
  162. package/dist/storage/scope-deletion-history.d.ts +8 -0
  163. package/dist/storage/scope-deletion-history.d.ts.map +1 -0
  164. package/dist/storage/scope-deletion-history.js +40 -0
  165. package/dist/storage/scope-deletion-history.js.map +1 -0
  166. package/dist/ui/ps-lite-debug.js +3981 -1109
  167. 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