my-durable 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +328 -2
- package/ai_docs/durable_pi/announcement.md +860 -0
- package/ai_docs/durable_pi/changelog.md +51 -0
- package/ai_docs/durable_pi/chord-delta-findings.md +362 -0
- package/ai_docs/durable_pi/coding-agent-integration.md +70 -0
- package/ai_docs/durable_pi/pico-v5-chord-usage.md +391 -0
- package/ai_docs/durable_pi/pico-v5-handoff.md +921 -0
- package/ai_docs/durable_pi/readme.md +617 -0
- package/ai_docs/durable_pi/sources.md +22 -0
- package/ai_docs/durable_pi/spec.md +4696 -0
- package/ai_docs/durable_pi/vacation-planner.md +57 -0
- package/ai_docs/pi/chord.md +268 -0
- package/ai_docs/pi/cli-integration.md +110 -0
- package/ai_docs/pi/cli.md +325 -0
- package/ai_docs/pi/codemode.md +211 -0
- package/ai_docs/pi/compaction.md +467 -0
- package/ai_docs/pi/configuration.md +51 -0
- package/ai_docs/pi/containerization.md +187 -0
- package/ai_docs/pi/custom-provider.md +177 -0
- package/ai_docs/pi/environment-variables.md +102 -0
- package/ai_docs/pi/extensions.md +278 -0
- package/ai_docs/pi/how-pi-works.md +53 -0
- package/ai_docs/pi/index.md +43 -0
- package/ai_docs/pi/json.md +230 -0
- package/ai_docs/pi/keybindings.md +198 -0
- package/ai_docs/pi/llama-cpp.md +118 -0
- package/ai_docs/pi/mcp.md +274 -0
- package/ai_docs/pi/message-types.md +265 -0
- package/ai_docs/pi/models.md +209 -0
- package/ai_docs/pi/overview.md +166 -0
- package/ai_docs/pi/packages.md +133 -0
- package/ai_docs/pi/pi-agent-core.md +568 -0
- package/ai_docs/pi/pi-ai.md +1942 -0
- package/ai_docs/pi/prompt-templates.md +63 -0
- package/ai_docs/pi/providers.md +204 -0
- package/ai_docs/pi/quickstart.md +140 -0
- package/ai_docs/pi/readme.md +99 -0
- package/ai_docs/pi/rpc-commands.md +864 -0
- package/ai_docs/pi/rpc-extension-ui.md +204 -0
- package/ai_docs/pi/rpc.md +197 -0
- package/ai_docs/pi/sdk.md +151 -0
- package/ai_docs/pi/security.md +103 -0
- package/ai_docs/pi/session-format.md +297 -0
- package/ai_docs/pi/sessions.md +70 -0
- package/ai_docs/pi/settings.md +173 -0
- package/ai_docs/pi/shell-aliases.md +97 -0
- package/ai_docs/pi/skills.md +97 -0
- package/ai_docs/pi/slash-commands.md +64 -0
- package/ai_docs/pi/sources.md +57 -0
- package/ai_docs/pi/terminal-setup.md +223 -0
- package/ai_docs/pi/termux.md +121 -0
- package/ai_docs/pi/themes.md +140 -0
- package/ai_docs/pi/tmux.md +59 -0
- package/ai_docs/pi/toolkit.md +168 -0
- package/ai_docs/pi/tui.md +129 -0
- package/ai_docs/pi/usage.md +98 -0
- package/ai_docs/pi/virtual-models.md +118 -0
- package/ai_docs/pi/windows.md +71 -0
- package/apps/dpi-backend/.nvmrc +1 -0
- package/apps/dpi-backend/API.md +99 -0
- package/apps/dpi-backend/README.md +119 -0
- package/apps/dpi-backend/deploy/backend.env.example +9 -0
- package/apps/dpi-backend/deploy/dpi-backend.plist +26 -0
- package/apps/dpi-backend/deploy/dpi-backend.service +22 -0
- package/apps/dpi-backend/package.json +33 -0
- package/apps/dpi-backend/prompts/one_sentence.md +3 -0
- package/apps/dpi-backend/prompts/smart_ass_opus_fix.md +169 -0
- package/apps/dpi-backend/scripts/check-contract.mjs +151 -0
- package/apps/dpi-backend/scripts/migrate-playground.ts +66 -0
- package/apps/dpi-backend/scripts/record-nested-fixture.mts +154 -0
- package/apps/dpi-backend/scripts/token.ts +57 -0
- package/apps/dpi-backend/src/addons.ts +139 -0
- package/apps/dpi-backend/src/auth.ts +20 -0
- package/apps/dpi-backend/src/config.ts +54 -0
- package/apps/dpi-backend/src/contract-check.ts +118 -0
- package/apps/dpi-backend/src/errors.ts +50 -0
- package/apps/dpi-backend/src/extensions.ts +510 -0
- package/apps/dpi-backend/src/faux.ts +80 -0
- package/apps/dpi-backend/src/history.ts +165 -0
- package/apps/dpi-backend/src/idempotency.ts +20 -0
- package/apps/dpi-backend/src/jev.ts +43 -0
- package/apps/dpi-backend/src/jobs.ts +790 -0
- package/apps/dpi-backend/src/main.ts +76 -0
- package/apps/dpi-backend/src/model-catalog.ts +180 -0
- package/apps/dpi-backend/src/pairing-files.ts +142 -0
- package/apps/dpi-backend/src/pairing.ts +459 -0
- package/apps/dpi-backend/src/routes.ts +217 -0
- package/apps/dpi-backend/src/shared-state.ts +85 -0
- package/apps/dpi-backend/src/store.ts +1240 -0
- package/apps/dpi-backend/src/streams.ts +256 -0
- package/apps/dpi-backend/src/tunnel.ts +955 -0
- package/apps/dpi-backend/tsconfig.json +13 -0
- package/apps/dpi-playground/README.md +83 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-CFGc7lHT.js +77 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/assets/index-DFdKpwm_.css +1 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/dist/index.html +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/index.html +15 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/anthropic.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/deepseek.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/durable-pi.svg +14 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/gemini.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/grok.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/kimi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/lab.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/meta.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/minimax.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/openai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/qwen.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/xiaomi.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/public/icons/zai.png +0 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/App.vue +180 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/commands.ts +119 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/composables.ts +129 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/hub.ts +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/icon-path.ts +7 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/ids.ts +2 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/jobs.ts +80 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/link-policy.ts +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown-policy.ts +115 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/markdown.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-close.ts +23 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair-handoff.ts +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/pair.ts +166 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/qrcode.ts +323 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/signature.ts +146 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/store.ts +373 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/client/stream.ts +183 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/AgentView.vue +342 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Composer.vue +198 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ContextBar.vue +254 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceCredentialsDialog.vue +40 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DeviceIcon.vue +56 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/DirBrowser.vue +95 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ExtensionsPanel.vue +96 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/HelpDialog.vue +69 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Inspector.vue +425 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/JobCard.vue +221 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/MobileNav.vue +39 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/ModelPicker.vue +71 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NetworkSidebar.vue +236 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/NewSessionDialog.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/OfflinePanel.vue +128 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PairDeviceWizard.vue +433 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/PromptDialog.vue +89 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/StateEditor.vue +179 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TasksPanel.vue +280 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/Toast.vue +11 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TokenManager.vue +175 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TopBar.vue +114 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TranscriptItem.vue +152 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TreeRow.vue +195 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/components/TunnelStatusBar.vue +107 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/extensions.css +105 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/jobs.css +655 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/main.ts +22 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/network.css +675 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/src/style.css +3203 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/tsconfig.json +16 -0
- package/apps/dpi-playground/apps/dpi-playground-fe/vite.config.ts +14 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/package.json +5 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/audit.ts +406 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/client-ip.ts +72 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/credentials.ts +265 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/guard.ts +43 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/http-errors.ts +156 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/identity.ts +60 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/limiter.ts +165 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/main.ts +460 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/monitor.ts +320 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/mux.ts +277 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/pairing.ts +816 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/proxy.ts +128 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/rotation.ts +583 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/seed.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/transport.ts +108 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-token.ts +11 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel-transport.ts +560 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/src/tunnel.ts +873 -0
- package/apps/dpi-playground/apps/dpi-playground-hub/tsconfig.json +13 -0
- package/apps/dpi-playground/package.json +37 -0
- package/apps/dpi-sdk/README.md +74 -0
- package/apps/dpi-sdk/package.json +20 -0
- package/apps/dpi-sdk/scripts/net.ts +73 -0
- package/apps/dpi-sdk/src/api.ts +631 -0
- package/apps/dpi-sdk/src/app-state.ts +111 -0
- package/apps/dpi-sdk/src/client.ts +330 -0
- package/apps/dpi-sdk/src/errors.ts +16 -0
- package/apps/dpi-sdk/src/jobs.ts +99 -0
- package/apps/dpi-sdk/src/models.ts +472 -0
- package/apps/dpi-sdk/src/node/chunk.ts +488 -0
- package/apps/dpi-sdk/src/node/config.ts +175 -0
- package/apps/dpi-sdk/src/node/drivers.ts +402 -0
- package/apps/dpi-sdk/src/node/ops.ts +114 -0
- package/apps/dpi-sdk/src/node/private-dir.ts +48 -0
- package/apps/dpi-sdk/src/node/registry-migration.ts +549 -0
- package/apps/dpi-sdk/src/node/registry.ts +390 -0
- package/apps/dpi-sdk/src/node/sse-writer.ts +151 -0
- package/apps/dpi-sdk/src/protocol/backoff.ts +31 -0
- package/apps/dpi-sdk/src/protocol/limits.ts +199 -0
- package/apps/dpi-sdk/src/protocol/pairing.ts +311 -0
- package/apps/dpi-sdk/src/protocol/rest-routes.ts +105 -0
- package/apps/dpi-sdk/src/protocol/tunnel.ts +331 -0
- package/apps/dpi-sdk/src/sse.ts +117 -0
- package/apps/dpi-sdk/src/transcript.ts +291 -0
- package/apps/dpi-sdk/tsconfig.json +13 -0
- package/apps/dpi-sdk/w +20 -0
- package/apps/dpi-tui/README.md +54 -0
- package/apps/dpi-tui/bin/dpi-tui.mjs +5 -0
- package/apps/dpi-tui/package.json +20 -0
- package/apps/dpi-tui/src/app.ts +433 -0
- package/apps/dpi-tui/src/commands.ts +151 -0
- package/apps/dpi-tui/src/main.ts +122 -0
- package/apps/dpi-tui/src/pairing.ts +287 -0
- package/apps/dpi-tui/src/pickers.ts +238 -0
- package/apps/dpi-tui/src/render.ts +164 -0
- package/apps/dpi-tui/src/theme.ts +57 -0
- package/apps/dpi-tui/tsconfig.json +14 -0
- package/bin/device.mjs +5 -0
- package/bin/hub.mjs +5 -0
- package/bin/tui.mjs +5 -0
- package/deploy/Caddyfile +120 -0
- package/docs/verification.md +102 -0
- package/dpi-network.example.yaml +62 -0
- package/images/01_durable_agent_network.svg +185 -0
- package/images/02_pi_made_durable.svg +204 -0
- package/images/03_restart_mid_tool.svg +168 -0
- package/images/07_one_api_every_client.svg +123 -0
- package/images/08_device_session_agent.svg +123 -0
- package/images/09_compact_keep_history.svg +143 -0
- package/images/10_shared_app_state.svg +177 -0
- package/images/11_durable_jobs_wait.svg +141 -0
- package/images/12_tools_change_mid_run.svg +231 -0
- package/images/13_hook_blocks_tool.svg +122 -0
- package/images/14_fork_at_any_entry.svg +119 -0
- package/images/15_web_client.png +0 -0
- package/images/16_terminal_client.svg +43 -0
- package/images/17_devices_are_data.svg +182 -0
- package/images/18_should_every_agent_be_durable.svg +118 -0
- package/images/19_sqlite_on_every_device.svg +155 -0
- package/images/20_admission_and_effects.svg +134 -0
- package/justfile +33 -0
- package/package.json +41 -4
- package/scripts/verify.mjs +210 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* network.sqlite schema v0 → v1 migration and maintenance (spec 07 §5/§7.2).
|
|
3
|
+
*
|
|
4
|
+
* Contract (all enforced here):
|
|
5
|
+
* - Writers must be stopped: a v0 file is migrated only while this process
|
|
6
|
+
* holds the file's EXCLUSIVE lock, which exists only when no other connection
|
|
7
|
+
* (hub, TUI, net script; idle or not) has it open. Otherwise abort.
|
|
8
|
+
* - Backup under that lock: VACUUM INTO a 0600 file (a consistent snapshot that
|
|
9
|
+
* includes the WAL, never a raw copy of the main file) → fsync → verify the
|
|
10
|
+
* copy opens as v0 with every row and passes integrity_check. One file.
|
|
11
|
+
* - One transaction rebuilds devices (15 legacy columns + transport='lan'),
|
|
12
|
+
* creates UID index, hub_meta, pairings, tunnel_credentials, tunnel_state,
|
|
13
|
+
* events + triggers; user_version=1 set INSIDE the transaction.
|
|
14
|
+
* - Any injected failure rolls back to an intact v0 database.
|
|
15
|
+
* - Rerun on valid v1 is a no-op; unknown/higher/partial-corrupt schema aborts
|
|
16
|
+
* (no CREATE IF NOT EXISTS masking).
|
|
17
|
+
* - This module never touches agent.sqlite / catalog / app-state (different domain).
|
|
18
|
+
*/
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
import { chmodSync, closeSync, existsSync, fstatSync, fsyncSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname } from "node:path";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { ensurePrivateDir } from "./private-dir.js";
|
|
24
|
+
|
|
25
|
+
export const REGISTRY_SCHEMA_VERSION = 1;
|
|
26
|
+
const UUID_CANONICAL = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
27
|
+
export const DEVICE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
28
|
+
|
|
29
|
+
/** The 15 legacy v0 columns, in order. */
|
|
30
|
+
const V0_COLUMNS = [
|
|
31
|
+
"id", "name", "kind", "url", "token", "source", "position", "device_uid",
|
|
32
|
+
"last_status", "last_error", "last_seen_at", "last_info_json", "last_tree_json",
|
|
33
|
+
"created_at", "updated_at",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
const V1_TABLES = [
|
|
37
|
+
"devices", "ui_state", "hub_meta", "pairings", "tunnel_credentials",
|
|
38
|
+
"tunnel_state", "events",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
/** v1 DDL for everything except `devices` (identical for fresh + migration). */
|
|
42
|
+
const V1_SCHEMA_SQL = `
|
|
43
|
+
CREATE UNIQUE INDEX IF NOT EXISTS devices_uid_unique ON devices(device_uid)
|
|
44
|
+
WHERE device_uid IS NOT NULL;
|
|
45
|
+
CREATE TABLE IF NOT EXISTS ui_state(
|
|
46
|
+
key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS hub_meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pairings(
|
|
49
|
+
device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE RESTRICT,
|
|
50
|
+
pairing_epoch TEXT NOT NULL UNIQUE,
|
|
51
|
+
public_key TEXT NOT NULL,
|
|
52
|
+
key_fingerprint TEXT NOT NULL,
|
|
53
|
+
status TEXT NOT NULL CHECK(status IN ('paired','revoked')),
|
|
54
|
+
credential_generation INTEGER NOT NULL CHECK(credential_generation >= 1),
|
|
55
|
+
rotation_id TEXT,
|
|
56
|
+
rotation_target_credential_id TEXT,
|
|
57
|
+
rotation_started_at INTEGER,
|
|
58
|
+
rotation_requested_at INTEGER,
|
|
59
|
+
paired_at INTEGER NOT NULL,
|
|
60
|
+
created_at INTEGER NOT NULL,
|
|
61
|
+
updated_at INTEGER NOT NULL,
|
|
62
|
+
CHECK((rotation_id IS NULL AND rotation_target_credential_id IS NULL
|
|
63
|
+
AND rotation_started_at IS NULL) OR
|
|
64
|
+
(rotation_id IS NOT NULL AND rotation_target_credential_id IS NOT NULL
|
|
65
|
+
AND rotation_started_at IS NOT NULL))
|
|
66
|
+
);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS tunnel_credentials(
|
|
68
|
+
credential_id TEXT PRIMARY KEY,
|
|
69
|
+
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE RESTRICT,
|
|
70
|
+
pairing_epoch TEXT NOT NULL,
|
|
71
|
+
generation INTEGER NOT NULL CHECK(generation >= 1),
|
|
72
|
+
token_hash TEXT NOT NULL UNIQUE
|
|
73
|
+
CHECK(length(token_hash)=64 AND token_hash NOT GLOB '*[^0-9a-f]*'),
|
|
74
|
+
status TEXT NOT NULL CHECK(status IN ('current','previous','retired','revoked')),
|
|
75
|
+
created_at INTEGER NOT NULL,
|
|
76
|
+
confirmed_at INTEGER,
|
|
77
|
+
retired_at INTEGER,
|
|
78
|
+
revoked_at INTEGER,
|
|
79
|
+
UNIQUE(device_id, pairing_epoch, generation),
|
|
80
|
+
CHECK((status='revoked') = (revoked_at IS NOT NULL)),
|
|
81
|
+
CHECK(status<>'retired' OR retired_at IS NOT NULL)
|
|
82
|
+
);
|
|
83
|
+
CREATE UNIQUE INDEX IF NOT EXISTS credentials_one_current ON tunnel_credentials(device_id)
|
|
84
|
+
WHERE status='current';
|
|
85
|
+
CREATE UNIQUE INDEX IF NOT EXISTS credentials_one_previous ON tunnel_credentials(device_id)
|
|
86
|
+
WHERE status='previous';
|
|
87
|
+
CREATE TRIGGER IF NOT EXISTS credentials_no_delete BEFORE DELETE ON tunnel_credentials
|
|
88
|
+
BEGIN SELECT RAISE(ABORT, 'credential history is retained'); END;
|
|
89
|
+
CREATE TRIGGER IF NOT EXISTS credentials_no_revive
|
|
90
|
+
BEFORE UPDATE OF status, token_hash, device_id, pairing_epoch ON tunnel_credentials
|
|
91
|
+
WHEN OLD.status='revoked' AND
|
|
92
|
+
(NEW.status<>'revoked' OR NEW.token_hash<>OLD.token_hash OR
|
|
93
|
+
NEW.device_id<>OLD.device_id OR NEW.pairing_epoch<>OLD.pairing_epoch)
|
|
94
|
+
BEGIN SELECT RAISE(ABORT, 'revoked credential is immutable'); END;
|
|
95
|
+
CREATE TABLE IF NOT EXISTS tunnel_state(
|
|
96
|
+
device_id TEXT PRIMARY KEY REFERENCES devices(id) ON DELETE RESTRICT,
|
|
97
|
+
connection_id TEXT NOT NULL,
|
|
98
|
+
connected_at INTEGER NOT NULL,
|
|
99
|
+
last_seen_at INTEGER NOT NULL,
|
|
100
|
+
reader_count INTEGER NOT NULL CHECK(reader_count BETWEEN 0 AND 128)
|
|
101
|
+
);
|
|
102
|
+
CREATE TABLE IF NOT EXISTS events(
|
|
103
|
+
id INTEGER PRIMARY KEY,
|
|
104
|
+
ts INTEGER NOT NULL,
|
|
105
|
+
actor TEXT NOT NULL,
|
|
106
|
+
action TEXT NOT NULL,
|
|
107
|
+
request_id TEXT,
|
|
108
|
+
connection_id TEXT,
|
|
109
|
+
outcome TEXT NOT NULL CHECK(outcome IN ('ok','denied')),
|
|
110
|
+
detail_json TEXT NOT NULL DEFAULT '{}'
|
|
111
|
+
CHECK(json_valid(detail_json) AND length(CAST(detail_json AS BLOB)) <= 4096)
|
|
112
|
+
);
|
|
113
|
+
CREATE TRIGGER IF NOT EXISTS events_no_update BEFORE UPDATE ON events
|
|
114
|
+
BEGIN SELECT RAISE(ABORT, 'audit is append-only'); END;
|
|
115
|
+
CREATE TRIGGER IF NOT EXISTS events_no_delete BEFORE DELETE ON events
|
|
116
|
+
BEGIN SELECT RAISE(ABORT, 'audit is append-only'); END;
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
const DEVICES_V1_DDL = `
|
|
120
|
+
CREATE TABLE {name}(
|
|
121
|
+
id TEXT PRIMARY KEY,
|
|
122
|
+
name TEXT NOT NULL,
|
|
123
|
+
kind TEXT NOT NULL CHECK(kind IN ('local','ssh','exe-dev','url')),
|
|
124
|
+
url TEXT,
|
|
125
|
+
token TEXT,
|
|
126
|
+
source TEXT NOT NULL DEFAULT 'ui' CHECK(source IN ('config','ui')),
|
|
127
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
device_uid TEXT,
|
|
129
|
+
last_status TEXT NOT NULL DEFAULT 'checking',
|
|
130
|
+
last_error TEXT,
|
|
131
|
+
last_seen_at INTEGER,
|
|
132
|
+
last_info_json TEXT,
|
|
133
|
+
last_tree_json TEXT,
|
|
134
|
+
created_at INTEGER NOT NULL,
|
|
135
|
+
updated_at INTEGER NOT NULL,
|
|
136
|
+
transport TEXT NOT NULL DEFAULT 'lan' CHECK(transport IN ('lan','tunnel')),
|
|
137
|
+
CHECK((transport='lan' AND url IS NOT NULL) OR
|
|
138
|
+
(transport='tunnel' AND url IS NULL AND token IS NULL))
|
|
139
|
+
)`;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Objects the v1 schema owns, derived FROM the v1 DDL itself (no second hand-maintained
|
|
143
|
+
* copy to drift), minus the two tables v0 already had. A v0 file that already contains
|
|
144
|
+
* one of these is not a v0 file: it is half-migrated or hand-edited, and migrating it
|
|
145
|
+
* with the IF-NOT-EXISTS DDL would silently carry the stray object into the new v1.
|
|
146
|
+
*/
|
|
147
|
+
const V1_ONLY_OBJECTS: readonly string[] = (() => {
|
|
148
|
+
const names = new Set<string>();
|
|
149
|
+
const ddl = `${DEVICES_V1_DDL.replace("{name}", "devices")};${V1_SCHEMA_SQL}`;
|
|
150
|
+
for (const m of ddl.matchAll(/\bCREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX|TRIGGER)\s+IF\s+NOT\s+EXISTS\s+([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
151
|
+
names.add(m[1]);
|
|
152
|
+
}
|
|
153
|
+
names.delete("devices"); // v0 and v1 both have it
|
|
154
|
+
names.delete("ui_state"); // v0 and v1 both have it
|
|
155
|
+
return [...names].sort();
|
|
156
|
+
})();
|
|
157
|
+
|
|
158
|
+
/** Column names of `table` from its CREATE TABLE statement in `ddl` (table-constraint rows skipped). */
|
|
159
|
+
function ddlColumns(ddl: string, table: string): readonly string[] {
|
|
160
|
+
const m = ddl.match(new RegExp(`\\bCREATE TABLE (?:IF NOT EXISTS )?${table}\\s*\\(([^;]*)\\)`, "i"));
|
|
161
|
+
if (!m) throw new Error(`cannot derive columns of ${table} from DDL`);
|
|
162
|
+
const cols: string[] = [];
|
|
163
|
+
let seg = "";
|
|
164
|
+
let depth = 0;
|
|
165
|
+
for (const ch of m[1]) {
|
|
166
|
+
if (ch === "(") depth++;
|
|
167
|
+
else if (ch === ")") depth--;
|
|
168
|
+
if (ch === "," && depth === 0) {
|
|
169
|
+
cols.push(seg.trim().split(/\s+/)[0]);
|
|
170
|
+
seg = "";
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
seg += ch;
|
|
174
|
+
}
|
|
175
|
+
cols.push(seg.trim().split(/\s+/)[0]);
|
|
176
|
+
return cols.filter(
|
|
177
|
+
(c) => c && !/^(PRIMARY|UNIQUE|CHECK|FOREIGN|CONSTRAINT)$/i.test(c),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The exact column set each v0 table must have (order included), derived from the DDL
|
|
183
|
+
* constants above — devices from V0_COLUMNS, ui_state from the v1 DDL, whose ui_state is
|
|
184
|
+
* identical to v0's (the only other table v0 had). No second hand-maintained copy to drift.
|
|
185
|
+
*/
|
|
186
|
+
const V0_TABLE_COLUMNS: readonly (readonly [string, readonly string[]])[] = [
|
|
187
|
+
["devices", V0_COLUMNS],
|
|
188
|
+
["ui_state", ddlColumns(V1_SCHEMA_SQL, "ui_state")],
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
export type MigrationFailPoint =
|
|
192
|
+
| "preflight"
|
|
193
|
+
| "after-backup"
|
|
194
|
+
| "mid-transaction"
|
|
195
|
+
| "before-user-version"
|
|
196
|
+
| "after-commit";
|
|
197
|
+
|
|
198
|
+
export interface MigrationOptions {
|
|
199
|
+
/** Test seam: throw `injected: <point>` when that phase is reached. */
|
|
200
|
+
failAt?: MigrationFailPoint;
|
|
201
|
+
/** Test seam: called as each phase is reached (crash drills block here and get killed). */
|
|
202
|
+
onPhase?: (point: MigrationFailPoint) => void;
|
|
203
|
+
/** `false` skips the cold backup (tests only). */
|
|
204
|
+
backup?: boolean;
|
|
205
|
+
/** How long to wait for other connections before giving up. Default 3000. */
|
|
206
|
+
busyTimeoutMs?: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface RegistryMigrationResult {
|
|
210
|
+
action: "fresh" | "migrated" | "noop";
|
|
211
|
+
version: number;
|
|
212
|
+
backupPath?: string;
|
|
213
|
+
hubId?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
class MigrationError extends Error {
|
|
217
|
+
constructor(message: string, readonly detail?: unknown) {
|
|
218
|
+
super(`registry migration: ${message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const DEFAULT_BUSY_TIMEOUT_MS = 3000;
|
|
223
|
+
|
|
224
|
+
function reached(opts: MigrationOptions, point: MigrationFailPoint): void {
|
|
225
|
+
opts.onPhase?.(point);
|
|
226
|
+
if (opts.failAt === point) throw new MigrationError(`injected: ${point}`);
|
|
227
|
+
}
|
|
228
|
+
const WRITERS_STILL_RUNNING =
|
|
229
|
+
"another process still has the registry open — stop all registry writers (hub, TUI, net scripts) and retry";
|
|
230
|
+
|
|
231
|
+
const tableExists = (db: DatabaseSync, name: string) =>
|
|
232
|
+
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
233
|
+
|
|
234
|
+
const userVersion = (db: DatabaseSync) =>
|
|
235
|
+
(db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
236
|
+
|
|
237
|
+
const integrity = (db: DatabaseSync) =>
|
|
238
|
+
(db.prepare("PRAGMA integrity_check").get() as { integrity_check: string }).integrity_check;
|
|
239
|
+
|
|
240
|
+
function columns(db: DatabaseSync, table: string): string[] {
|
|
241
|
+
return (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((r) => r.name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function validateV1(db: DatabaseSync): void {
|
|
245
|
+
for (const table of V1_TABLES) {
|
|
246
|
+
if (!tableExists(db, table)) throw new MigrationError(`partial schema: missing table ${table}`);
|
|
247
|
+
}
|
|
248
|
+
const cols = columns(db, "devices");
|
|
249
|
+
if (cols.length !== 16 || !cols.includes("transport")) {
|
|
250
|
+
throw new MigrationError("partial schema: devices is not v1 (16 columns incl. transport)");
|
|
251
|
+
}
|
|
252
|
+
for (const trigger of ["credentials_no_delete", "credentials_no_revive", "events_no_update", "events_no_delete"]) {
|
|
253
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='trigger' AND name=?").get(trigger))
|
|
254
|
+
throw new MigrationError(`partial schema: missing trigger ${trigger}`);
|
|
255
|
+
}
|
|
256
|
+
if (integrity(db) !== "ok") throw new MigrationError("integrity_check failed on v1 database");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const userTables = (db: DatabaseSync) =>
|
|
260
|
+
(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all() as { name: string }[]).map(
|
|
261
|
+
(r) => r.name,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Creates v1 directly in one transaction, so a crash or a starter arriving at
|
|
266
|
+
* the same moment never sees a half-built schema. Returns undefined when
|
|
267
|
+
* another starter built it first.
|
|
268
|
+
*/
|
|
269
|
+
function createFresh(db: DatabaseSync): RegistryMigrationResult | undefined {
|
|
270
|
+
db.exec("BEGIN IMMEDIATE");
|
|
271
|
+
try {
|
|
272
|
+
if (userTables(db).length > 0) {
|
|
273
|
+
db.exec("ROLLBACK");
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
db.exec(DEVICES_V1_DDL.replace("{name}", "devices") + ";" + V1_SCHEMA_SQL);
|
|
277
|
+
const hubId = randomUUID();
|
|
278
|
+
db.prepare("INSERT INTO hub_meta(key,value) VALUES('hub_id',?)").run(hubId);
|
|
279
|
+
db.exec(`PRAGMA user_version=${REGISTRY_SCHEMA_VERSION};`);
|
|
280
|
+
db.exec("COMMIT");
|
|
281
|
+
return { action: "fresh", version: REGISTRY_SCHEMA_VERSION, hubId };
|
|
282
|
+
} catch (e) {
|
|
283
|
+
try {
|
|
284
|
+
db.exec("ROLLBACK");
|
|
285
|
+
} catch {
|
|
286
|
+
/* nothing was open */
|
|
287
|
+
}
|
|
288
|
+
throw e;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Only v0 migrates; a v1 file is validated, anything else is refused. */
|
|
293
|
+
function assertMigratable(version: number): void {
|
|
294
|
+
if (version > REGISTRY_SCHEMA_VERSION) {
|
|
295
|
+
throw new MigrationError(`database schema v${version} is newer than supported v${REGISTRY_SCHEMA_VERSION}`);
|
|
296
|
+
}
|
|
297
|
+
if (version !== 0) throw new MigrationError(`unknown schema version ${version}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const sleepMs = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
301
|
+
|
|
302
|
+
const isBusy = (e: unknown) => {
|
|
303
|
+
const code = ((e as { errcode?: number }).errcode ?? 0) & 0xff;
|
|
304
|
+
return code === 5 || code === 6; // SQLITE_BUSY / SQLITE_LOCKED
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Opens `path` and runs `ready` on it, closing and retrying with jitter while
|
|
309
|
+
* another process holds a lock. SQLite's own busy handler does not cover
|
|
310
|
+
* everything: the first switch of a new file to WAL fails at once, and a
|
|
311
|
+
* connection in EXCLUSIVE mode keeps its shared lock while it waits to upgrade,
|
|
312
|
+
* so two starters waiting that way would hold each other out until both timed out.
|
|
313
|
+
*/
|
|
314
|
+
function openWhenFree(path: string, busyTimeoutMs: number, ready: (db: DatabaseSync) => void): DatabaseSync {
|
|
315
|
+
const deadline = Date.now() + busyTimeoutMs;
|
|
316
|
+
for (;;) {
|
|
317
|
+
const db = new DatabaseSync(path);
|
|
318
|
+
try {
|
|
319
|
+
ready(db);
|
|
320
|
+
return db;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
db.close();
|
|
323
|
+
if (!isBusy(e) || Date.now() >= deadline) throw e;
|
|
324
|
+
sleepMs(5 + Math.random() * 20);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Opens `path` as its only connection, or throws. In WAL mode every open
|
|
331
|
+
* connection, idle ones included, keeps a shared lock on the main file, so an
|
|
332
|
+
* EXCLUSIVE lock exists only while no hub, TUI or net script has the registry
|
|
333
|
+
* open. A TRUNCATE checkpoint cannot show that: it succeeds against idle
|
|
334
|
+
* connections.
|
|
335
|
+
*/
|
|
336
|
+
function openExclusive(path: string, busyTimeoutMs: number): DatabaseSync {
|
|
337
|
+
try {
|
|
338
|
+
return openWhenFree(path, busyTimeoutMs, (db) => {
|
|
339
|
+
db.exec("PRAGMA busy_timeout=0; PRAGMA locking_mode=EXCLUSIVE; PRAGMA foreign_keys=ON;");
|
|
340
|
+
db.prepare("SELECT count(*) FROM sqlite_master").get(); // the first read takes the lock
|
|
341
|
+
});
|
|
342
|
+
} catch (e) {
|
|
343
|
+
if (isBusy(e)) throw new MigrationError(WRITERS_STILL_RUNNING, e);
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** v0 preflight: no stray v1 objects, exact column set per v0 table, canonical ids/uids, no duplicates. */
|
|
349
|
+
function preflightV0(db: DatabaseSync): void {
|
|
350
|
+
// First: anything the v1 schema owns means this is not a v0 file at all. Refused before
|
|
351
|
+
// the backup is written and before any write touches the registry (the caller's
|
|
352
|
+
// `reached(opts, "preflight")` hook still fires only after preflightV0 returns).
|
|
353
|
+
const stray = db
|
|
354
|
+
.prepare(
|
|
355
|
+
`SELECT type, name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND name IN (${V1_ONLY_OBJECTS.map(() => "?").join(",")}) ORDER BY name`,
|
|
356
|
+
)
|
|
357
|
+
.all(...V1_ONLY_OBJECTS) as { type: string; name: string }[];
|
|
358
|
+
if (stray.length > 0)
|
|
359
|
+
throw new MigrationError(
|
|
360
|
+
`v0 database already contains v1-only object(s): ${stray.map((s) => `${s.type} ${s.name}`).join(", ")}`,
|
|
361
|
+
);
|
|
362
|
+
if (!tableExists(db, "ui_state")) throw new MigrationError("unknown v0 schema: missing ui_state");
|
|
363
|
+
// Exact column set for every v0 table: extra (or missing/renamed) columns mean a
|
|
364
|
+
// hand-edited file or an unknown v0.x — refused like everything else here, never
|
|
365
|
+
// silently migrated or dropped.
|
|
366
|
+
for (const [table, expected] of V0_TABLE_COLUMNS) {
|
|
367
|
+
const cols = columns(db, table);
|
|
368
|
+
if (cols.length !== expected.length || expected.some((c, i) => cols[i] !== c)) {
|
|
369
|
+
const unknown = cols.filter((c) => !expected.includes(c));
|
|
370
|
+
throw new MigrationError(
|
|
371
|
+
`unknown v0 schema: table ${table} columns ${JSON.stringify(cols)}` +
|
|
372
|
+
(unknown.length > 0 ? ` — unknown column(s) ${unknown.join(", ")}` : ""),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const rows = db.prepare("SELECT id, url, device_uid FROM devices").all() as {
|
|
377
|
+
id: string; url: string | null; device_uid: string | null;
|
|
378
|
+
}[];
|
|
379
|
+
const seenUid = new Set<string>();
|
|
380
|
+
for (const row of rows) {
|
|
381
|
+
if (!DEVICE_ID_PATTERN.test(row.id)) throw new MigrationError(`non-canonical device id ${JSON.stringify(row.id)} — resolve manually (no trim)`);
|
|
382
|
+
if (row.url === null || row.url.trim() === "") throw new MigrationError(`device ${row.id} has empty/NULL url — resolve manually`);
|
|
383
|
+
if (row.device_uid !== null) {
|
|
384
|
+
if (!UUID_CANONICAL.test(row.device_uid)) throw new MigrationError(`non-canonical device_uid on ${row.id}: ${JSON.stringify(row.device_uid)} — resolve manually (no trim)`);
|
|
385
|
+
if (seenUid.has(row.device_uid)) throw new MigrationError(`duplicate device_uid ${row.device_uid} — operator must resolve before migration`);
|
|
386
|
+
seenUid.add(row.device_uid);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function syncFile(file: string): void {
|
|
392
|
+
const fd = openSync(file, "r");
|
|
393
|
+
try {
|
|
394
|
+
fsyncSync(fd);
|
|
395
|
+
} finally {
|
|
396
|
+
closeSync(fd);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* fsyncs a DIRECTORY (POSIX: a read-only fd on a directory can be fsynced), so the
|
|
402
|
+
* directory entry of a file that was just created and fsynced survives power loss.
|
|
403
|
+
* Syncing only the file is not enough: the file's data can be durable while the
|
|
404
|
+
* link that names it is not, and the file then simply does not exist after a crash.
|
|
405
|
+
* Exported for a focused unit test — the fsync itself is not observable from JS.
|
|
406
|
+
*/
|
|
407
|
+
export function syncDirectory(dir: string): void {
|
|
408
|
+
const fd = openSync(dir, "r");
|
|
409
|
+
try {
|
|
410
|
+
if (!fstatSync(fd).isDirectory()) throw new Error(`not a directory: ${dir}`);
|
|
411
|
+
fsyncSync(fd);
|
|
412
|
+
} finally {
|
|
413
|
+
closeSync(fd);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Snapshots the registry with SQLite itself (VACUUM INTO), so the copy includes
|
|
419
|
+
* everything committed to the WAL and is taken without a second handle on the
|
|
420
|
+
* file: on POSIX, closing any descriptor of a file drops every lock the process
|
|
421
|
+
* holds on it, which would silently end the exclusive lock the caller relies on.
|
|
422
|
+
* Then proves the copy opens as v0 with every row. The copy is one file.
|
|
423
|
+
*/
|
|
424
|
+
function writeBackup(db: DatabaseSync, backupPath: string, expectedRows: number): void {
|
|
425
|
+
const sidecars = [`${backupPath}-wal`, `${backupPath}-shm`];
|
|
426
|
+
// Leftovers of an aborted earlier attempt must not be replayed onto the new copy.
|
|
427
|
+
// SQLite itself drops a WAL found beside a zero-page file, so removing the sidecars
|
|
428
|
+
// is belt and braces: the outcome (not this call) is what the stale-WAL test pins.
|
|
429
|
+
for (const stale of [backupPath, ...sidecars]) rmSync(stale, { force: true });
|
|
430
|
+
writeFileSync(backupPath, "", { mode: 0o600, flag: "wx" }); // VACUUM INTO fills an empty file; 0600 from the start
|
|
431
|
+
db.prepare("VACUUM INTO ?").run(backupPath);
|
|
432
|
+
syncFile(backupPath); // the only way back: durable before the registry is touched
|
|
433
|
+
syncDirectory(dirname(backupPath)); // and its directory entry too: a durable file no one can name is no backup
|
|
434
|
+
const bak = new DatabaseSync(backupPath, { readOnly: true });
|
|
435
|
+
try {
|
|
436
|
+
if (integrity(bak) !== "ok") throw new MigrationError("backup integrity_check failed");
|
|
437
|
+
const rows = (bak.prepare("SELECT count(*) AS n FROM devices").get() as { n: number }).n;
|
|
438
|
+
if (rows !== expectedRows) throw new MigrationError(`backup row count ${rows} != ${expectedRows}`);
|
|
439
|
+
if (columns(bak, "devices").length !== 15) throw new MigrationError("backup is not readable as v0");
|
|
440
|
+
} finally {
|
|
441
|
+
bak.close();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Runs with the file's exclusive lock held; `db` is the only connection to it. */
|
|
446
|
+
function migrateV0(db: DatabaseSync, path: string, opts: MigrationOptions): RegistryMigrationResult {
|
|
447
|
+
// What was read before the lock was taken may be stale: a peer that started
|
|
448
|
+
// at the same moment can have migrated while this process waited for it.
|
|
449
|
+
const version = userVersion(db);
|
|
450
|
+
if (version === REGISTRY_SCHEMA_VERSION) {
|
|
451
|
+
validateV1(db);
|
|
452
|
+
return { action: "noop", version };
|
|
453
|
+
}
|
|
454
|
+
assertMigratable(version);
|
|
455
|
+
|
|
456
|
+
preflightV0(db);
|
|
457
|
+
reached(opts, "preflight");
|
|
458
|
+
|
|
459
|
+
const preCount = (db.prepare("SELECT count(*) AS n FROM devices").get() as { n: number }).n;
|
|
460
|
+
|
|
461
|
+
const backupPath = `${path}.pre-v1.bak`;
|
|
462
|
+
if (opts.backup !== false) writeBackup(db, backupPath, preCount);
|
|
463
|
+
reached(opts, "after-backup");
|
|
464
|
+
|
|
465
|
+
// ---- one-transaction migration
|
|
466
|
+
db.exec("BEGIN IMMEDIATE");
|
|
467
|
+
try {
|
|
468
|
+
db.exec(DEVICES_V1_DDL.replace("{name}", "devices_v1_new"));
|
|
469
|
+
db.exec(
|
|
470
|
+
`INSERT INTO devices_v1_new(id,name,kind,url,token,source,position,device_uid,
|
|
471
|
+
last_status,last_error,last_seen_at,last_info_json,last_tree_json,
|
|
472
|
+
created_at,updated_at,transport)
|
|
473
|
+
SELECT id,name,kind,url,token,source,position,device_uid,
|
|
474
|
+
last_status,last_error,last_seen_at,last_info_json,last_tree_json,
|
|
475
|
+
created_at,updated_at,'lan'
|
|
476
|
+
FROM devices`,
|
|
477
|
+
);
|
|
478
|
+
reached(opts, "mid-transaction");
|
|
479
|
+
db.exec("DROP TABLE devices; ALTER TABLE devices_v1_new RENAME TO devices;");
|
|
480
|
+
db.exec(V1_SCHEMA_SQL);
|
|
481
|
+
const hubId = randomUUID();
|
|
482
|
+
db.prepare("INSERT INTO hub_meta(key,value) VALUES('hub_id',?)").run(hubId);
|
|
483
|
+
reached(opts, "before-user-version");
|
|
484
|
+
db.exec(`PRAGMA user_version=${REGISTRY_SCHEMA_VERSION};`);
|
|
485
|
+
db.exec("COMMIT");
|
|
486
|
+
} catch (e) {
|
|
487
|
+
try {
|
|
488
|
+
db.exec("ROLLBACK");
|
|
489
|
+
} catch {
|
|
490
|
+
/* already rolled back by close */
|
|
491
|
+
}
|
|
492
|
+
throw e;
|
|
493
|
+
}
|
|
494
|
+
if (integrity(db) !== "ok") throw new MigrationError("integrity_check failed after migration");
|
|
495
|
+
const fk = db.prepare("PRAGMA foreign_key_check").all();
|
|
496
|
+
if (fk.length > 0) throw new MigrationError(`foreign_key_check failed: ${JSON.stringify(fk)}`);
|
|
497
|
+
// Re-validate the v1 invariants on the thing just produced — never hand back an
|
|
498
|
+
// action:"migrated" for a schema this module itself would refuse on a rerun.
|
|
499
|
+
validateV1(db);
|
|
500
|
+
reached(opts, "after-commit");
|
|
501
|
+
return {
|
|
502
|
+
action: "migrated",
|
|
503
|
+
version: REGISTRY_SCHEMA_VERSION,
|
|
504
|
+
backupPath,
|
|
505
|
+
hubId: (db.prepare("SELECT value FROM hub_meta WHERE key='hub_id'").get() as { value: string }).value,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Bring the registry file at `path` to schema v1. Owns its connections: a
|
|
511
|
+
* v0 file is migrated while holding the only connection to it (snapshot backup,
|
|
512
|
+
* then one transaction). Throws on any failure; the caller (hub/TUI boot)
|
|
513
|
+
* must not start on throw.
|
|
514
|
+
*/
|
|
515
|
+
export function migrateRegistryFile(path: string, opts: MigrationOptions = {}): RegistryMigrationResult {
|
|
516
|
+
const busyTimeoutMs = opts.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS;
|
|
517
|
+
ensurePrivateDir(dirname(path));
|
|
518
|
+
const fresh = !existsSync(path);
|
|
519
|
+
let db = openWhenFree(path, busyTimeoutMs, (c) => {
|
|
520
|
+
if (fresh) chmodSync(path, 0o600);
|
|
521
|
+
c.exec(`PRAGMA busy_timeout=${busyTimeoutMs}; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;`);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
if (userTables(db).length === 0) {
|
|
526
|
+
const created = createFresh(db);
|
|
527
|
+
if (created) return created;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const version = userVersion(db);
|
|
531
|
+
if (version === REGISTRY_SCHEMA_VERSION) {
|
|
532
|
+
validateV1(db);
|
|
533
|
+
return { action: "noop", version };
|
|
534
|
+
}
|
|
535
|
+
assertMigratable(version);
|
|
536
|
+
|
|
537
|
+
// v0: migrating needs the file to itself. Release this connection first so
|
|
538
|
+
// two processes starting together cannot each hold the other out.
|
|
539
|
+
db.close();
|
|
540
|
+
db = openExclusive(path, busyTimeoutMs);
|
|
541
|
+
return migrateV0(db, path, opts);
|
|
542
|
+
} finally {
|
|
543
|
+
try {
|
|
544
|
+
db.close();
|
|
545
|
+
} catch {
|
|
546
|
+
/* closed already */
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|