@pasko70/pibo 3.4.4 → 3.5.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 (91) hide show
  1. package/dist/agent-runtime/routed-session.js +111 -25
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/agent-runtimes/pi/adapter.js +1 -0
  4. package/dist/agent-runtimes/pi/routed-session.js +2 -2
  5. package/dist/apps/chat/bounded-event-stream.js +98 -0
  6. package/dist/apps/chat/chat-settings-routes.js +3 -3
  7. package/dist/apps/chat/data/chat-data-mappers.js +40 -14
  8. package/dist/apps/chat/data/event-command-service.js +30 -21
  9. package/dist/apps/chat/data/history-query-service.js +34 -25
  10. package/dist/apps/chat/data/read-state-service.js +37 -3
  11. package/dist/apps/chat/data/session-query-service.js +19 -11
  12. package/dist/apps/chat/data/timeline-query-service.js +19 -7
  13. package/dist/apps/chat/message-command-dispatcher.js +149 -0
  14. package/dist/apps/chat/output-compactor.js +9 -0
  15. package/dist/apps/chat/output-event-policy.js +9 -1
  16. package/dist/apps/chat/stream.js +21 -3
  17. package/dist/apps/chat/telemetry-retention-service.js +113 -8
  18. package/dist/apps/chat/trace-response-cache.js +46 -0
  19. package/dist/apps/chat/trace-v2.js +12 -6
  20. package/dist/apps/chat/trace.js +1 -1
  21. package/dist/apps/chat/web-app.js +645 -297
  22. package/dist/apps/chat-ui/assets/{dist-V06sfuZa.js → dist-B-auLrzD.js} +1 -1
  23. package/dist/apps/chat-ui/assets/{dist-CH3SvpYV.js → dist-BA_dsINH.js} +1 -1
  24. package/dist/apps/chat-ui/assets/{dist-23lt_7qm.js → dist-eJZar_0-.js} +1 -1
  25. package/dist/apps/chat-ui/assets/{dist-Bf2KScPo.js → dist-wNNR2Bci.js} +1 -1
  26. package/dist/apps/chat-ui/assets/{dist-DsgL8w-W.js → dist-zcmEsIEp.js} +1 -1
  27. package/dist/apps/chat-ui/assets/index-DEkbN5Vo.js +229 -0
  28. package/dist/apps/chat-ui/assets/index-DZK6Tzil.css +1 -0
  29. package/dist/apps/chat-ui/index.html +2 -2
  30. package/dist/apps/chat-vscode-web/assets/index-DZgW1fCB.js +44 -0
  31. package/dist/apps/chat-vscode-web/index.html +1 -1
  32. package/dist/cli-session/localSessionSource.js +3 -2
  33. package/dist/compute/pool/seeds.js +25 -3
  34. package/dist/core/events.js +4 -0
  35. package/dist/core/output-render-sequence.js +65 -6
  36. package/dist/core/provider-capacity.js +33 -0
  37. package/dist/core/provider-telemetry.js +21 -6
  38. package/dist/core/runtime-capacity.js +174 -0
  39. package/dist/core/runtime-telemetry.js +40 -12
  40. package/dist/core/session-router.js +178 -13
  41. package/dist/data/async-chat-reads.js +38 -0
  42. package/dist/data/async-chat-storage.js +96 -0
  43. package/dist/data/async-telemetry-maintenance.js +9 -0
  44. package/dist/data/bounded-worker-client.js +256 -0
  45. package/dist/data/chat-read-projections.js +159 -0
  46. package/dist/data/chat-read-worker.js +73 -0
  47. package/dist/data/chat-storage-worker.js +167 -0
  48. package/dist/data/ingest-service.js +152 -18
  49. package/dist/data/message-command-store.js +290 -0
  50. package/dist/data/payload-store.js +92 -11
  51. package/dist/data/pibo-store.js +10 -8
  52. package/dist/data/schema.js +39 -4
  53. package/dist/data/session-store.js +3 -1
  54. package/dist/data/storage-backup.js +278 -0
  55. package/dist/data/storage-maintenance.js +344 -0
  56. package/dist/data/storage-verification-worker.js +25 -0
  57. package/dist/data/telemetry-capture.js +188 -0
  58. package/dist/data/telemetry-command.js +3 -0
  59. package/dist/data/telemetry-maintenance-worker.js +40 -0
  60. package/dist/data/telemetry-maintenance.js +110 -0
  61. package/dist/data/telemetry-retention.js +16 -7
  62. package/dist/data/telemetry-worker.js +111 -0
  63. package/dist/data/telemetry-writer.js +150 -83
  64. package/dist/data/telemetry.js +5 -0
  65. package/dist/debug/index.js +207 -1
  66. package/dist/debug/message-queue.js +108 -0
  67. package/dist/debug/output-collision-repair.js +140 -0
  68. package/dist/debug/output-integrity.js +38 -2
  69. package/dist/debug/output-repair.js +1 -0
  70. package/dist/debug/storage-backup.js +41 -0
  71. package/dist/debug/storage-maintenance.js +78 -0
  72. package/dist/debug/telemetry-capture.js +66 -0
  73. package/dist/gateway/cli.js +71 -7
  74. package/dist/gateway/server.js +3 -0
  75. package/dist/providers/openai-gpt56.js +11 -6
  76. package/dist/reliability/store.js +119 -23
  77. package/dist/session-ui/terminalRows.js +54 -13
  78. package/dist/sessions/pibo-data-store.js +19 -14
  79. package/dist/shared/debug-features.js +4 -0
  80. package/dist/shared/model-inference-metrics.js +23 -0
  81. package/dist/shared/trace-event-projection.js +69 -2
  82. package/dist/shared/trace-history.js +9 -0
  83. package/dist/shared/trace-live-reducer.js +1 -0
  84. package/dist/shared/trace-patch-nodes.js +19 -0
  85. package/dist/web/channel.js +117 -12
  86. package/dist/web/http.js +135 -41
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/dist/apps/chat-ui/assets/index-BOceJ0jM.css +0 -1
  90. package/dist/apps/chat-ui/assets/index-BOemYq-V.js +0 -228
  91. package/dist/apps/chat-vscode-web/assets/index-CMwTB8o8.js +0 -43
@@ -1,6 +1,7 @@
1
+ import { pipeline } from "node:stream";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
- import { gunzipSync, gzipSync } from "node:zlib";
3
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createGunzip, gunzipSync, gzipSync } from "node:zlib";
4
+ import { createReadStream, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { dirname, extname, join, resolve } from "node:path";
5
6
  import { piboHomePath } from "../core/pibo-home.js";
6
7
  const MAX_SYNC_PAYLOAD_GZIP_BYTES = 512 * 1024;
@@ -23,30 +24,55 @@ export class PiboPayloadMetadataConflictError extends Error {
23
24
  export class PayloadStore {
24
25
  db;
25
26
  rootDir;
26
- constructor(db, rootDir = piboHomePath("payloads")) {
27
+ constructor(db, rootDir = piboHomePath("payloads"), readOnly = false) {
27
28
  this.db = db;
28
29
  this.rootDir = rootDir === ":memory:" ? rootDir : resolve(rootDir);
29
- if (this.rootDir !== ":memory:")
30
+ if (!readOnly && this.rootDir !== ":memory:")
30
31
  mkdirSync(this.rootDir, { recursive: true });
31
32
  }
32
33
  writePayload(input) {
34
+ return this.commitPreparedPayload(this.preparePayload(input));
35
+ }
36
+ /** Performs hashing, compression and atomic file publication before a DB transaction begins. */
37
+ preparePayload(input) {
33
38
  const contentType = input.contentType ?? defaultContentType(input.value);
34
39
  const createdAt = input.createdAt ?? new Date().toISOString();
35
40
  const bytes = payloadToBytes(input.value, contentType);
36
41
  const sha256 = createHash("sha256").update(bytes).digest("hex");
37
42
  const existing = this.findByIdentity(sha256, contentType, input.retentionClass);
38
43
  if (existing) {
39
- this.db.prepare("UPDATE payloads SET ref_count = ref_count + 1 WHERE id = ?").run(existing.id);
40
- return this.getPayload(existing.id) ?? existing;
44
+ return { ...existing, refCount: 1, status: "staged" };
41
45
  }
42
46
  const shouldCompress = bytes.byteLength <= MAX_SYNC_PAYLOAD_GZIP_BYTES;
43
47
  const encoding = shouldCompress ? "gzip" : "identity";
44
48
  const bytesToStore = shouldCompress ? gzipSync(bytes) : bytes;
45
- const compressedByteSize = shouldCompress ? bytesToStore.byteLength : null;
49
+ const compressedByteSize = shouldCompress ? bytesToStore.byteLength : undefined;
46
50
  const relativePath = buildRelativePayloadPath(sha256, contentType, input.retentionClass, encoding);
47
51
  const absolutePath = this.rootDir === ":memory:" ? relativePath : join(this.rootDir, relativePath);
48
52
  writePayloadFile(absolutePath, bytesToStore);
49
- const id = input.id ?? `payload_${randomUUID()}`;
53
+ return {
54
+ id: input.id ?? `payload_${randomUUID()}`,
55
+ sha256,
56
+ storageKind: "file",
57
+ storagePath: relativePath,
58
+ contentType,
59
+ encoding,
60
+ byteSize: bytes.byteLength,
61
+ compressedByteSize,
62
+ previewText: previewTextFromValue(input.value),
63
+ retentionClass: input.retentionClass,
64
+ refCount: 1,
65
+ status: "staged",
66
+ createdAt,
67
+ };
68
+ }
69
+ /** Links a previously published payload file with only bounded metadata SQL. */
70
+ commitPreparedPayload(prepared) {
71
+ const existing = this.findByIdentity(prepared.sha256, prepared.contentType, prepared.retentionClass);
72
+ if (existing) {
73
+ this.db.prepare("UPDATE payloads SET ref_count = ref_count + 1 WHERE id = ?").run(existing.id);
74
+ return this.getPayload(existing.id) ?? existing;
75
+ }
50
76
  this.db.prepare(`
51
77
  INSERT INTO payloads (
52
78
  id,
@@ -64,10 +90,10 @@ export class PayloadStore {
64
90
  created_at,
65
91
  last_verified_at
66
92
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
67
- `).run(id, sha256, "file", relativePath, contentType, encoding, bytes.byteLength, compressedByteSize, previewTextFromValue(input.value) ?? null, input.retentionClass, 1, "committed", createdAt, createdAt);
68
- const stored = this.getPayload(id);
93
+ `).run(prepared.id, prepared.sha256, "file", prepared.storagePath ?? null, prepared.contentType, prepared.encoding, prepared.byteSize, prepared.compressedByteSize ?? null, prepared.previewText ?? null, prepared.retentionClass, 1, "committed", prepared.createdAt, prepared.createdAt);
94
+ const stored = this.getPayload(prepared.id);
69
95
  if (!stored)
70
- throw new Error(`Failed to persist payload \"${id}\"`);
96
+ throw new Error(`Failed to persist payload \"${prepared.id}\"`);
71
97
  return stored;
72
98
  }
73
99
  getPayload(id) {
@@ -88,6 +114,61 @@ export class PayloadStore {
88
114
  return bytes;
89
115
  throw new Error(`Unsupported payload encoding \"${payload.encoding}\"`);
90
116
  }
117
+ openPayloadStream(id) {
118
+ const payload = this.getPayload(id);
119
+ if (!payload?.storagePath)
120
+ throw new Error("Payload not found");
121
+ if (payload.encoding !== "identity" && payload.encoding !== "gzip")
122
+ throw new Error("Unsupported payload encoding");
123
+ const source = createReadStream(this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath));
124
+ if (payload.encoding === "identity")
125
+ return source;
126
+ const decoded = createGunzip();
127
+ pipeline(source, decoded, () => { }); // Pipeline closes both ends on cancellation or read failure.
128
+ return decoded;
129
+ }
130
+ /** Read a bounded uncompressed range without materializing the entire payload. */
131
+ async readPayloadRange(id, offset, limit) {
132
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1)
133
+ throw new RangeError("Invalid payload range");
134
+ const payload = this.getPayload(id);
135
+ if (!payload?.storagePath)
136
+ throw new Error("Payload not found");
137
+ if (offset >= payload.byteSize)
138
+ return Buffer.alloc(0);
139
+ const path = this.rootDir === ":memory:" ? payload.storagePath : join(this.rootDir, payload.storagePath);
140
+ const end = Math.min(payload.byteSize, offset + limit);
141
+ const source = createReadStream(path, payload.encoding === "identity" ? { start: offset, end: end - 1 } : {});
142
+ if (payload.encoding !== "identity" && payload.encoding !== "gzip") {
143
+ source.destroy();
144
+ throw new Error("Unsupported payload encoding");
145
+ }
146
+ const stream = payload.encoding === "gzip" ? source.pipe(createGunzip()) : source;
147
+ const forwardError = (error) => stream.destroy(error);
148
+ if (stream !== source)
149
+ source.on("error", forwardError);
150
+ let position = payload.encoding === "identity" ? offset : 0;
151
+ const parts = [];
152
+ try {
153
+ for await (const value of stream) {
154
+ const bytes = Buffer.from(value);
155
+ const from = Math.max(0, offset - position), to = Math.min(bytes.length, end - position);
156
+ if (to > from)
157
+ parts.push(Buffer.from(bytes.subarray(from, to)));
158
+ position += bytes.length;
159
+ if (position >= end)
160
+ break;
161
+ }
162
+ }
163
+ finally {
164
+ stream.destroy();
165
+ source.destroy();
166
+ }
167
+ const result = Buffer.concat(parts);
168
+ if (result.length !== end - offset)
169
+ throw new Error("Incomplete payload range");
170
+ return result;
171
+ }
91
172
  readPayloadText(id) {
92
173
  return Buffer.from(this.readPayloadBytes(id)).toString("utf8");
93
174
  }
@@ -23,10 +23,12 @@ export class PiboDataStore {
23
23
  telemetry;
24
24
  constructor(path = piboHomePath("pibo.sqlite"), options = {}) {
25
25
  this.path = path === ":memory:" ? path : resolve(path);
26
- const insidePiboHome = ensurePrivatePiboHomeForPath(this.path);
27
- if (this.path !== ":memory:")
26
+ const insidePiboHome = !options.readOnly && ensurePrivatePiboHomeForPath(this.path);
27
+ if (!options.readOnly && this.path !== ":memory:")
28
28
  mkdirSync(dirname(this.path), { recursive: true });
29
- this.db = new DatabaseSync(this.path);
29
+ this.db = new DatabaseSync(this.path, { readOnly: options.readOnly === true });
30
+ // Connection-only configuration also protects the initial schema read.
31
+ this.db.exec("PRAGMA busy_timeout = 5000");
30
32
  try {
31
33
  assertSupportedPiboDataSchemaVersion(this.db);
32
34
  }
@@ -34,14 +36,14 @@ export class PiboDataStore {
34
36
  this.db.close();
35
37
  throw error;
36
38
  }
37
- if (insidePiboHome)
39
+ if (!options.readOnly && insidePiboHome)
38
40
  protectPrivateFileSync(this.path);
39
- this.db.exec("PRAGMA busy_timeout = 5000");
40
41
  this.db.exec("PRAGMA foreign_keys = ON");
41
- if (this.path !== ":memory:")
42
+ if (!options.readOnly && this.path !== ":memory:")
42
43
  this.db.exec("PRAGMA journal_mode = WAL");
43
- applyPiboDataSchema(this.db);
44
- this.payloads = new PayloadStore(this.db, options.payloadRootDir ?? piboHomePath("payloads"));
44
+ if (!options.readOnly)
45
+ applyPiboDataSchema(this.db);
46
+ this.payloads = new PayloadStore(this.db, options.payloadRootDir ?? piboHomePath("payloads"), options.readOnly);
45
47
  this.eventLog = new PiboEventLogStore(this.db);
46
48
  this.messages = new MessageStore(this.db);
47
49
  this.observations = new ObservationStore(this.db);
@@ -1,5 +1,9 @@
1
- export const PIBO_DATA_SCHEMA_VERSION = 9;
1
+ import { CHAT_READ_PROJECTION_SCHEMA, CHAT_NAVIGATION_REVISION_SCHEMA } from "./chat-read-projections.js";
2
+ import { TELEMETRY_MAINTENANCE_SCHEMA } from "./telemetry-maintenance.js";
3
+ import { MESSAGE_COMMAND_SCHEMA } from "./message-command-store.js";
4
+ export const PIBO_DATA_SCHEMA_VERSION = 14;
2
5
  const NATIVE_HISTORY_FALLBACK_SCHEMA_VERSION = 5;
6
+ const TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION = 14;
3
7
  const retiredScopeColumn = ["owner", "scope"].join("_");
4
8
  const payloadTableDefinition = `
5
9
  id TEXT PRIMARY KEY,
@@ -198,6 +202,14 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
198
202
  if (rebuildPayloadTable)
199
203
  rebuildPayloadTableForMetadataIdentity(db);
200
204
  hooks.afterStep?.("payload-metadata-identity");
205
+ if (previousVersion < TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION) {
206
+ db.exec(`
207
+ DROP TRIGGER IF EXISTS chat_unread_event_insert;
208
+ DROP TRIGGER IF EXISTS chat_unread_event_update;
209
+ DROP INDEX IF EXISTS idx_event_log_unread_stream;
210
+ DROP INDEX IF EXISTS idx_event_log_unread_session_stream;
211
+ `);
212
+ }
201
213
  db.exec(`
202
214
  CREATE TABLE IF NOT EXISTS sessions (
203
215
  id TEXT PRIMARY KEY,
@@ -660,8 +672,10 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
660
672
  ON event_log(session_id, stream_id);
661
673
  CREATE INDEX IF NOT EXISTS idx_event_log_unread_session_stream
662
674
  ON event_log(session_id, stream_id)
663
- WHERE (retention_class = 'chat_message' AND type IN ('user.message.accepted', 'assistant_message'))
664
- OR type = 'session_error';
675
+ WHERE type = 'message_finished';
676
+ CREATE INDEX IF NOT EXISTS idx_event_log_unread_error_session_stream
677
+ ON event_log(session_id, stream_id)
678
+ WHERE type = 'session_error';
665
679
  CREATE INDEX IF NOT EXISTS idx_event_log_session_sequence_stream
666
680
  ON event_log(session_id, session_sequence DESC, stream_id DESC);
667
681
  CREATE INDEX IF NOT EXISTS idx_event_log_session_type_sequence_stream
@@ -710,6 +724,12 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
710
724
  ON telemetry_turns(retention_class, updated_at);
711
725
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_started
712
726
  ON telemetry_phases(turn_id, started_at ASC);
727
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_name
728
+ ON telemetry_phases(turn_id, name, COALESCE(last_progress_at, started_at) DESC, created_at DESC) WHERE status='open';
729
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_open_turn
730
+ ON telemetry_phases(turn_id, started_at ASC, created_at ASC) WHERE status='open';
731
+ CREATE INDEX IF NOT EXISTS idx_telemetry_phases_turn_name
732
+ ON telemetry_phases(turn_id, name);
713
733
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_session_status
714
734
  ON telemetry_phases(pibo_session_id, status, last_progress_at);
715
735
  CREATE INDEX IF NOT EXISTS idx_telemetry_phases_provider_request
@@ -759,6 +779,19 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
759
779
  CREATE INDEX IF NOT EXISTS idx_telemetry_tool_calls_retention_updated
760
780
  ON telemetry_tool_calls(retention_class, updated_at);
761
781
  `);
782
+ db.exec(CHAT_READ_PROJECTION_SCHEMA);
783
+ if (previousVersion < TERMINAL_MESSAGE_UNREAD_SCHEMA_VERSION) {
784
+ db.exec(`
785
+ DELETE FROM chat_unread_counts;
786
+ DELETE FROM chat_unread_index;
787
+ UPDATE chat_read_backfill
788
+ SET event_cursor = 0,
789
+ event_target = (SELECT COALESCE(MAX(stream_id), 0) FROM event_log)
790
+ WHERE id = 1;
791
+ `);
792
+ }
793
+ db.exec(CHAT_NAVIGATION_REVISION_SCHEMA);
794
+ db.exec(TELEMETRY_MAINTENANCE_SCHEMA);
762
795
  hooks.afterStep?.("schema");
763
796
  db.exec(`
764
797
  INSERT OR IGNORE INTO session_runtime_bindings (
@@ -798,6 +831,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
798
831
  END;
799
832
  `);
800
833
  hooks.afterStep?.("render-high-water");
834
+ db.exec("CREATE INDEX IF NOT EXISTS idx_event_log_sequence_repair_candidates ON event_log(session_id) WHERE session_id IS NOT NULL AND (session_sequence IS NULL OR session_sequence <= 0)");
801
835
  // Always inspect for interrupted pre-atomic v7 repairs. A previous process
802
836
  // may have written negative temporary values before setting user_version.
803
837
  db.exec(`
@@ -807,7 +841,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
807
841
  );
808
842
  INSERT INTO pibo_v7_sequence_repair_sessions (session_id)
809
843
  SELECT DISTINCT session_id
810
- FROM event_log
844
+ FROM event_log INDEXED BY idx_event_log_sequence_repair_candidates
811
845
  WHERE session_id IS NOT NULL
812
846
  AND (session_sequence IS NULL OR session_sequence <= 0);
813
847
  `);
@@ -858,6 +892,7 @@ function applyPiboDataSchemaInTransaction(db, hooks, previousVersion, tablesToRe
858
892
  }
859
893
  }
860
894
  hooks.afterStep?.("runtime-binding-metadata");
895
+ db.exec(MESSAGE_COMMAND_SCHEMA);
861
896
  db.exec(`PRAGMA user_version = ${PIBO_DATA_SCHEMA_VERSION}`);
862
897
  hooks.afterStep?.("user-version");
863
898
  }
@@ -31,7 +31,7 @@ export class SessionStore {
31
31
  now,
32
32
  ];
33
33
  const assignments = [
34
- "pi_session_id = excluded.pi_session_id",
34
+ ...(input.preserveRuntimeBinding ? [] : ["pi_session_id = excluded.pi_session_id"]),
35
35
  "room_id = excluded.room_id",
36
36
  "root_session_id = excluded.root_session_id",
37
37
  "parent_id = excluded.parent_id",
@@ -53,6 +53,8 @@ export class SessionStore {
53
53
  VALUES (${baseColumns.map(() => "?").join(", ")})
54
54
  ON CONFLICT(id) DO UPDATE SET ${assignments.join(", ")}
55
55
  `).run(...values);
56
+ if (input.preserveRuntimeBinding)
57
+ return;
56
58
  const binding = input.session.runtimeBinding
57
59
  ?? createLegacyPiRuntimeSessionBinding(input.session.id, input.session.piSessionId, input.session.createdAt);
58
60
  this.db.prepare(`
@@ -0,0 +1,278 @@
1
+ import { DatabaseSync, backup } from "node:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream, createWriteStream, statSync } from "node:fs";
4
+ import { mkdir, open, readFile, rename, stat, copyFile } from "node:fs/promises";
5
+ import { basename, dirname, resolve, relative, join, isAbsolute } from "node:path";
6
+ import { createGunzip } from "node:zlib";
7
+ import { pipeline } from "node:stream/promises";
8
+ const MANIFEST = "manifest.json", CATALOG = "payloads.jsonl", DATABASE = "snapshot.sqlite";
9
+ function ownedPath(root, path) { const target = resolve(root, path), rel = relative(resolve(root), target); if (isAbsolute(path) || rel === ".." || rel.startsWith("../") || rel.startsWith("..\\"))
10
+ throw Error("Backup path escapes its directory"); return target; }
11
+ async function syncFile(path) { const file = await open(path, "r"); try {
12
+ await file.sync();
13
+ }
14
+ finally {
15
+ await file.close();
16
+ } }
17
+ async function publishManifest(root, manifest) { const temp = join(root, MANIFEST + ".tmp"), file = await open(temp, "w", 0o600); try {
18
+ await file.writeFile(JSON.stringify(manifest) + "\n");
19
+ await file.sync();
20
+ }
21
+ finally {
22
+ await file.close();
23
+ } await rename(temp, join(root, MANIFEST)); await syncFile(root); }
24
+ export async function readStorageBackupManifest(root) { const path = join(root, MANIFEST); if ((await stat(path)).size > 16384)
25
+ throw Error("Backup manifest exceeds budget"); const m = JSON.parse(await readFile(path, "utf8")); if (m.format !== "pibo-storage-backup-v1" || !["snapshot", "payloads", "complete"].includes(m.status) || !Number.isSafeInteger(m.maxBytes) || !Number.isSafeInteger(m.maxPayloads))
26
+ throw Error("Unsupported backup manifest"); return m; }
27
+ async function digest(path, maximum, encoding, signal) { const input = createReadStream(path, { highWaterMark: 65536, signal }); const stream = encoding === "gzip" ? input.pipe(createGunzip()) : input; let bytes = 0; const hash = createHash("sha256"); try {
28
+ for await (const chunk of stream) {
29
+ signal?.throwIfAborted();
30
+ bytes += chunk.length;
31
+ if (bytes > maximum)
32
+ throw Error("Backup byte quota exceeded");
33
+ hash.update(chunk);
34
+ }
35
+ return { sha256: hash.digest("hex"), bytes };
36
+ }
37
+ finally {
38
+ input.destroy();
39
+ stream.destroy();
40
+ } }
41
+ /** Explicit offline operator operation. The SQLite online-backup API includes committed WAL data. */
42
+ export async function createStorageBackup(input) {
43
+ const milliseconds = input.maxMilliseconds ?? 60000;
44
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 1 || milliseconds > 3600000)
45
+ throw Error("Invalid backup time quota");
46
+ const timeout = AbortSignal.timeout(milliseconds);
47
+ input = { ...input, signal: input.signal ? AbortSignal.any([input.signal, timeout]) : timeout };
48
+ const root = resolve(input.destination), source = resolve(input.source), payloadRoot = resolve(input.payloadRoot), deadline = Date.now() + (input.maxMilliseconds ?? 60000);
49
+ const check = () => { input.signal?.throwIfAborted(); if (Date.now() > deadline)
50
+ throw Error("Backup time budget exhausted; resume explicitly"); };
51
+ let manifest;
52
+ let snapshotVerified = false;
53
+ if (input.resume) {
54
+ manifest = await readStorageBackupManifest(root);
55
+ if (manifest.source !== source || manifest.payloadRoot !== payloadRoot)
56
+ throw Error("Backup resume source does not match manifest");
57
+ }
58
+ else {
59
+ const maxBytes = input.maxBytes ?? 1024 * 1024 * 1024, maxPayloads = input.maxPayloads ?? 100000;
60
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 4096 || !Number.isSafeInteger(maxPayloads) || maxPayloads < 1 || maxPayloads > 1000000)
61
+ throw Error("Invalid backup quota");
62
+ await mkdir(root, { mode: 0o700 });
63
+ manifest = { format: "pibo-storage-backup-v1", status: "snapshot", source, payloadRoot, createdAt: new Date().toISOString(), payloadCount: 0, payloadBytes: 0, cursor: "", maxBytes, maxPayloads };
64
+ await publishManifest(root, manifest);
65
+ }
66
+ if (manifest.status === "complete") {
67
+ await verifyStorageBackup(root, input.signal);
68
+ return manifest;
69
+ }
70
+ if (manifest.status === "snapshot") {
71
+ check();
72
+ const walBytes = () => { try {
73
+ return statSync(source + "-wal").size;
74
+ }
75
+ catch {
76
+ return 0;
77
+ } };
78
+ const initialWalBytes = walBytes(), maxWalGrowth = input.maxWalGrowthBytes ?? 64 * 1024 * 1024;
79
+ if (!Number.isSafeInteger(maxWalGrowth) || maxWalGrowth < 0)
80
+ throw Error("Invalid WAL growth quota");
81
+ const db = new DatabaseSync(source, { readOnly: true });
82
+ const temporary = join(root, DATABASE + ".partial");
83
+ try {
84
+ db.exec("PRAGMA busy_timeout=10; BEGIN");
85
+ const pages = db.prepare("PRAGMA page_count").get().page_count, pageSize = db.prepare("PRAGMA page_size").get().page_size;
86
+ if (pages * pageSize > manifest.maxBytes)
87
+ throw Error("Database exceeds backup byte quota");
88
+ await backup(db, temporary, { rate: 128, progress: ({ totalPages, remainingPages }) => { check(); if (walBytes() - initialWalBytes > maxWalGrowth)
89
+ throw Error("Backup WAL growth quota exceeded; source snapshot released"); if (totalPages * pageSize > manifest.maxBytes)
90
+ throw Error("Database exceeds backup byte quota"); input.onProgress?.({ stage: "snapshot", copied: (totalPages - remainingPages) * pageSize }); } });
91
+ }
92
+ finally {
93
+ if (db.isTransaction)
94
+ db.exec("ROLLBACK");
95
+ db.close();
96
+ }
97
+ await syncFile(temporary);
98
+ await rename(temporary, join(root, DATABASE));
99
+ const hash = await digest(join(root, DATABASE), manifest.maxBytes, undefined, input.signal);
100
+ snapshotVerified = true;
101
+ manifest.databaseSha256 = hash.sha256;
102
+ manifest.databaseBytes = hash.bytes;
103
+ manifest.status = "payloads";
104
+ await publishManifest(root, manifest);
105
+ }
106
+ const database = join(root, DATABASE);
107
+ if (!snapshotVerified && (await digest(database, manifest.maxBytes, undefined, input.signal)).sha256 !== manifest.databaseSha256)
108
+ throw Error("Backup snapshot hash changed");
109
+ const db = new DatabaseSync(database, { readOnly: true });
110
+ try {
111
+ if (db.prepare("PRAGMA quick_check").get().quick_check !== "ok")
112
+ throw Error("Backup SQLite integrity check failed");
113
+ // Rebuild the small cursor from the fsynced catalog, including a file published before a crash.
114
+ let count = 0, bytes = 0, cursor = "";
115
+ const catalog = join(root, CATALOG);
116
+ const catalogFile = await open(catalog, "a+", 0o600);
117
+ try {
118
+ const size = (await catalogFile.stat()).size;
119
+ if (size) {
120
+ const tail = Buffer.alloc(Math.min(8192, size));
121
+ await catalogFile.read(tail, 0, tail.length, size - tail.length);
122
+ if (tail.at(-1) !== 10) {
123
+ const newline = tail.lastIndexOf(10);
124
+ if (newline < 0 && size > 8192)
125
+ throw Error("Invalid partial backup catalog");
126
+ await catalogFile.truncate(newline < 0 ? 0 : size - tail.length + newline + 1);
127
+ await catalogFile.sync();
128
+ }
129
+ }
130
+ }
131
+ finally {
132
+ await catalogFile.close();
133
+ }
134
+ for await (const row of catalogRows(catalog, manifest.maxPayloads)) {
135
+ check();
136
+ if (row.id <= cursor)
137
+ throw Error("Backup catalog order is invalid");
138
+ const hash = await digest(ownedPath(join(root, "payloads"), row.path), manifest.maxBytes, undefined, input.signal);
139
+ if (hash.sha256 !== row.storedSha256 || hash.bytes !== row.bytes)
140
+ throw Error("Backup payload changed");
141
+ count++;
142
+ bytes += row.bytes;
143
+ cursor = row.id;
144
+ }
145
+ manifest.payloadCount = count;
146
+ manifest.payloadBytes = bytes;
147
+ manifest.cursor = cursor;
148
+ const hasPayloads = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='payloads'").get());
149
+ while (hasPayloads) {
150
+ check();
151
+ const rows = db.prepare("SELECT id,storage_path,sha256,encoding,byte_size,status FROM payloads WHERE id>? ORDER BY id LIMIT 128").all(manifest.cursor);
152
+ if (!rows.length)
153
+ break;
154
+ for (const row of rows) {
155
+ check();
156
+ if (!row.storage_path || row.status !== "committed")
157
+ throw Error("Snapshot contains unavailable payload metadata");
158
+ if (manifest.payloadCount >= manifest.maxPayloads)
159
+ throw Error("Backup payload count quota exceeded");
160
+ const sourcePath = ownedPath(payloadRoot, row.storage_path), target = ownedPath(join(root, "payloads"), row.storage_path), size = (await stat(sourcePath)).size;
161
+ if ((manifest.databaseBytes ?? 0) + manifest.payloadBytes + size > manifest.maxBytes)
162
+ throw Error("Backup byte quota exceeded");
163
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
164
+ const temporary = target + ".partial";
165
+ await pipeline(createReadStream(sourcePath, { highWaterMark: 65536 }), createWriteStream(temporary, { mode: 0o600 }), { signal: input.signal });
166
+ const raw = await digest(temporary, manifest.maxBytes, undefined, input.signal), content = await digest(temporary, row.byte_size, row.encoding, input.signal);
167
+ if (content.sha256 !== row.sha256 || content.bytes !== row.byte_size)
168
+ throw Error("Source payload failed content verification");
169
+ await syncFile(temporary);
170
+ await rename(temporary, target);
171
+ await syncFile(dirname(target));
172
+ const entry = { id: row.id, path: row.storage_path, sha256: row.sha256, storedSha256: raw.sha256, bytes: raw.bytes, encoding: row.encoding, contentBytes: row.byte_size };
173
+ const file = await open(catalog, "a", 0o600);
174
+ try {
175
+ await file.writeFile(JSON.stringify(entry) + "\n");
176
+ await file.sync();
177
+ }
178
+ finally {
179
+ await file.close();
180
+ }
181
+ manifest.payloadCount++;
182
+ manifest.payloadBytes += raw.bytes;
183
+ manifest.cursor = row.id;
184
+ input.onProgress?.({ stage: "payloads", copied: manifest.payloadCount });
185
+ }
186
+ await publishManifest(root, manifest);
187
+ }
188
+ manifest.catalogSha256 = (await digest(catalog, Math.max(1024, manifest.maxPayloads * 4096), undefined, input.signal)).sha256;
189
+ manifest.status = "complete";
190
+ await publishManifest(root, manifest);
191
+ return manifest;
192
+ }
193
+ finally {
194
+ db.close();
195
+ }
196
+ }
197
+ async function* catalogRows(path, maximum) {
198
+ const stream = createReadStream(path, { highWaterMark: 65536, encoding: "utf8" });
199
+ let count = 0, pending = "";
200
+ const parse = (line) => { if (line.length > 4096 || ++count > maximum)
201
+ throw Error("Backup catalog exceeds quota"); const row = JSON.parse(line); if (typeof row.id !== "string" || typeof row.path !== "string" || !Number.isSafeInteger(row.bytes) || row.bytes < 0)
202
+ throw Error("Invalid backup catalog row"); return row; };
203
+ try {
204
+ for await (const chunk of stream) {
205
+ pending += chunk;
206
+ let newline;
207
+ while ((newline = pending.indexOf("\n")) >= 0) {
208
+ const line = pending.slice(0, newline);
209
+ pending = pending.slice(newline + 1);
210
+ yield parse(line);
211
+ }
212
+ if (pending.length > 4096)
213
+ throw Error("Backup catalog line exceeds quota");
214
+ }
215
+ if (pending)
216
+ throw Error("Incomplete backup catalog line");
217
+ }
218
+ finally {
219
+ stream.destroy();
220
+ }
221
+ }
222
+ export async function verifyStorageBackup(root, signal) {
223
+ const m = await readStorageBackupManifest(root);
224
+ if (m.status !== "complete")
225
+ throw Error("Backup is incomplete");
226
+ if ((await digest(join(root, DATABASE), m.maxBytes, undefined, signal)).sha256 !== m.databaseSha256 || (await digest(join(root, CATALOG), Math.max(1024, m.maxPayloads * 4096), undefined, signal)).sha256 !== m.catalogSha256)
227
+ throw Error("Backup manifest hash mismatch");
228
+ const db = new DatabaseSync(join(root, DATABASE), { readOnly: true });
229
+ let payloads = 0, bytes = m.databaseBytes ?? 0, previousId = "";
230
+ try {
231
+ if (db.prepare("PRAGMA quick_check").get().quick_check !== "ok")
232
+ throw Error("Backup database failed verification");
233
+ const hasPayloads = Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='payloads'").get());
234
+ for await (const row of catalogRows(join(root, CATALOG), m.maxPayloads)) {
235
+ signal?.throwIfAborted();
236
+ if (row.id <= previousId)
237
+ throw Error("Backup catalog order is invalid");
238
+ previousId = row.id;
239
+ const metadata = hasPayloads ? db.prepare("SELECT sha256,storage_path,byte_size FROM payloads WHERE id=?").get(row.id) : undefined;
240
+ if (!metadata || metadata.sha256 !== row.sha256 || metadata.storage_path !== row.path || metadata.byte_size !== row.contentBytes)
241
+ throw Error("Catalog does not match snapshot");
242
+ const path = ownedPath(join(root, "payloads"), row.path);
243
+ const raw = await digest(path, m.maxBytes, undefined, signal), content = await digest(path, row.contentBytes, row.encoding, signal);
244
+ if (raw.sha256 !== row.storedSha256 || raw.bytes !== row.bytes || content.sha256 !== row.sha256 || content.bytes !== row.contentBytes)
245
+ throw Error("Backup payload hash mismatch");
246
+ payloads++;
247
+ bytes += raw.bytes;
248
+ if (bytes > m.maxBytes)
249
+ throw Error("Backup exceeds byte quota");
250
+ }
251
+ const expected = hasPayloads ? Number(db.prepare("SELECT COUNT(*) AS count FROM payloads").get().count) : 0;
252
+ if (payloads !== expected || payloads !== m.payloadCount)
253
+ throw Error("Backup payload coverage mismatch");
254
+ return { payloads, bytes };
255
+ }
256
+ finally {
257
+ db.close();
258
+ }
259
+ }
260
+ export async function restoreStorageBackup(root, destination, signal) {
261
+ await verifyStorageBackup(root, signal);
262
+ const m = await readStorageBackupManifest(root), name = basename(m.source);
263
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name))
264
+ throw Error("Unsafe restored database name");
265
+ await mkdir(destination, { mode: 0o700 });
266
+ const payloadRoot = join(destination, "payloads"), database = join(destination, name);
267
+ for await (const row of catalogRows(join(root, CATALOG), m.maxPayloads)) {
268
+ signal?.throwIfAborted();
269
+ const target = ownedPath(payloadRoot, row.path);
270
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
271
+ await copyFile(ownedPath(join(root, "payloads"), row.path), target);
272
+ await syncFile(target);
273
+ }
274
+ await copyFile(join(root, DATABASE), database);
275
+ await syncFile(database);
276
+ await syncFile(destination);
277
+ return { database, payloadRoot };
278
+ }