artifacty 0.12.0 → 0.13.0

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/CLAUDE.md CHANGED
@@ -18,7 +18,7 @@ For English and Korean documentation, follow the [writing style guide](docs/writ
18
18
  - `node src/mcp-server.js` or `npm run mcp` — run the MCP stdio server.
19
19
  - `node src/cli.js help` — inspect CLI usage.
20
20
 
21
- Run `npm run release:check` before claiming a release-ready change. There is no build step; this is plain ESM run directly by Node `>=22.5`.
21
+ Run `npm run release:check` before claiming a release-ready change. There is no build step; this is plain ESM run directly by Node `>=22.12.0`.
22
22
 
23
23
  ## Architecture
24
24
 
@@ -43,6 +43,8 @@ The store lives at `ARTIFACTY_HOME` or `~/.artifacty` by default.
43
43
 
44
44
  Versions are append-only. To correct a historical version, use the admin repair (`replaceArtifactVersion`) or delete (`deleteArtifactVersion`) path. These paths record every change in the audit log. Do not modify prior content files through other paths.
45
45
 
46
+ Before changing upgrade behavior, read [upgrade snapshots and recovery](docs/upgrade-backups.md). `src/lib/upgrade-backup.js` protects schema migrations with a mandatory verified snapshot. Optional application-update snapshots use `ARTIFACTY_BACKUP_ON_UPDATE=true`. Keep recovery commands independent of normal storage initialization so they cannot trigger migration. Stop every server and MCP process before restoring a store. Test recovery only with temporary stores.
47
+
46
48
  ### Security Model
47
49
 
48
50
  - The HTTP server binds to `127.0.0.1` by default.
@@ -55,6 +57,8 @@ Versions are append-only. To correct a historical version, use the admin repair
55
57
 
56
58
  The create/import/edit screens use CodeMirror 6 for Markdown, HTML, JSON, and text editing. CodeMirror assets are served locally through allowlisted `/vendor/npm/*` routes, not from a CDN. UI text defaults to English and supports Korean with `?lang=ko`. Keep each document in its existing language unless a translation is requested.
57
59
 
60
+ Mermaid uses version 12.1.0 with the existing `classic` look, `dagre` layout, and `default` theme. Its browser code requires ES2024 support, such as Safari 17.4 or newer. The optional Babel 8 React renderer requires Node `^22.18.0 || >=24.11.0`.
61
+
58
62
  ### MCP Server
59
63
 
60
64
  The MCP server is a hand-rolled line-delimited JSON-RPC 2.0 implementation using protocol version `2025-06-18`. Tool results include both `content[].text` and `structuredContent`. Keep `artifacty_publish` as a backwards-compatible alias for `artifacty_create`.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Publish](https://github.com/raeseoklee/artifacty/actions/workflows/publish.yml/badge.svg)](https://github.com/raeseoklee/artifacty/actions/workflows/publish.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/artifacty.svg)](https://www.npmjs.com/package/artifacty)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [![Node.js >=22.5](https://img.shields.io/badge/node-%3E%3D22.5-339933.svg)](package.json)
6
+ [![Node.js >=22.12.0](https://img.shields.io/badge/node-%3E%3D22.12.0-339933.svg)](package.json)
7
7
 
8
8
  Artifacty gives AI-generated artifacts a local home that agents can share. Claude, Codex, Gemini, GitHub Copilot, Cursor, and other MCP tools can publish artifacts to the same store. Other agents can find, read, and update them without copying content through chat.
9
9
 
@@ -15,11 +15,13 @@ Artifacts turn session output into shareable, versioned pages. Artifacty keeps t
15
15
 
16
16
  ## Installation
17
17
 
18
- Artifacty requires Node.js 22.5 or newer.
18
+ Artifacty requires Node.js 22.12.0 or newer.
19
19
 
20
20
  The optional React renderer uses Babel 8. Its supported Node.js versions are
21
21
  `^22.18.0 || >=24.11.0`. Use a compatible version if you enable that renderer.
22
22
 
23
+ The browser renderer uses Mermaid 12.1.0. Use a current browser with ES2024 support, such as Safari 17.4 or newer. Mermaid keeps the `classic` look, `dagre` layout, and `default` theme.
24
+
23
25
  Install the CLI globally from npm:
24
26
 
25
27
  ```bash
@@ -298,6 +300,16 @@ artifacty index rebuild
298
300
  artifacty integrity
299
301
  ```
300
302
 
303
+ ### Upgrade Snapshots
304
+
305
+ Before an existing store needs a schema migration, Artifacty creates and verifies a mandatory local snapshot. The snapshot includes the raw SQLite database, referenced version files, and legacy index. If snapshot verification fails, migration stops.
306
+
307
+ Set `ARTIFACTY_BACKUP_ON_UPDATE=true` to also snapshot application-version changes. If an existing store has no recorded application version, its first open with this setting creates a baseline snapshot. `ARTIFACTY_UPGRADE_BACKUP_KEEP` retains the latest three automatic snapshots by default (valid range: 1–1000).
308
+
309
+ Use `artifacty backups list`, `artifacty backups verify <id>`, and `artifacty backups restore <id> --confirm restore-store`. Stop every server and MCP process before an upgrade or restore. Restore creates a recovery snapshot before replacing the store. It also restores credentials and security state; previously revoked sessions, tokens, and links can become valid again.
310
+
311
+ These local snapshots are separate from portable `artifacty backup`/`export` JSON bundles. See [upgrade snapshots and recovery](docs/upgrade-backups.md) for verification, corrupt-store recovery, and interrupted-restore procedures.
312
+
301
313
  ### Semantic Search
302
314
 
303
315
  `?mode=keyword|semantic|hybrid` on `GET /api/artifacts`, the dashboard, `artifacty list --mode`, and the MCP `artifacty_list` tool add optional natural-language search on top of FTS5, without any bundled model or mandatory dependency:
@@ -705,6 +717,8 @@ The table lists every `ARTIFACTY_*` variable that Artifacty reads. `node src/cli
705
717
  | Variable | Purpose | Default |
706
718
  | --- | --- | --- |
707
719
  | `ARTIFACTY_HOME` | Storage directory | `~/.artifacty` |
720
+ | `ARTIFACTY_BACKUP_ON_UPDATE` | Snapshot existing stores before application-version changes; schema-migration snapshots are always required | `false` |
721
+ | `ARTIFACTY_UPGRADE_BACKUP_KEEP` | Automatic upgrade snapshots to retain; integer from 1 to 1000 | `3` |
708
722
  | `ARTIFACTY_URL` | Public URL override; otherwise CLI/MCP read the last running server URL | — |
709
723
  | `ARTIFACTY_HOST` | Bind host for the HTTP server | `127.0.0.1` |
710
724
  | `ARTIFACTY_PORT` | Bind port for the HTTP server | `8787` |
@@ -2,6 +2,8 @@
2
2
 
3
3
  Artifacty exposes one shared MCP stdio server and one local browser server.
4
4
 
5
+ Use Node.js 22.12.0 or newer. The optional Babel 8 React renderer requires Node `^22.18.0 || >=24.11.0`. Mermaid 12.1.0 requires an ES2024-capable browser, such as Safari 17.4 or newer.
6
+
5
7
  ## Start the Browser Server
6
8
 
7
9
  ```bash
@@ -418,3 +420,5 @@ in one JSON bundle. It excludes users, sessions, API token records, and audit
418
420
  logs. A store import replaces the target artifact records and removes
419
421
  unreferenced version files. Before importing, select a new or intended
420
422
  `ARTIFACTY_HOME`.
423
+
424
+ Upgrade snapshots protect schema migrations and optionally application updates. They preserve raw database security state and differ from JSON export backups. Stop all server and MCP processes before recovery. See [upgrade snapshots and recovery](upgrade-backups.md).
@@ -23,6 +23,11 @@ Run the local diagnostics pass:
23
23
  artifacty doctor
24
24
  ```
25
25
 
26
+ - Verify recovery commands against temporary stores only. See [upgrade snapshots and recovery](upgrade-backups.md).
27
+ - Confirm mandatory schema snapshots complete before migrations change the store.
28
+ - Check the Node.js 22.12.0 minimum. The optional Babel 8 renderer requires Node `^22.18.0 || >=24.11.0`.
29
+ - Check Mermaid 12.1.0 in an ES2024-capable browser. Preserve its `classic` look, `dagre` layout, and `default` theme.
30
+
26
31
  ## Packaging
27
32
 
28
33
  - Confirm `package.json` `version` and `files` are intentional.
@@ -0,0 +1,89 @@
1
+ # Upgrade Snapshots and Recovery
2
+
3
+ Artifacty creates a local snapshot before it migrates an existing store to a newer schema. This snapshot is mandatory. If snapshot creation or verification fails, Artifacty stops before migration.
4
+
5
+ Use upgrade snapshots to recover the original store state. Use `artifacty backup` and `artifacty export` for portable JSON backups and store transfers. These are separate backup systems. `artifacty restore <id>` restores an archived artifact; it does not restore a store snapshot.
6
+
7
+ ## When Artifacty Creates a Snapshot
8
+
9
+ - **Schema migration:** Artifacty always snapshots an existing store before migration. This includes a legacy `index.json` store. A new empty store needs no snapshot.
10
+ - **Application update:** Set `ARTIFACTY_BACKUP_ON_UPDATE=true` to also snapshot an existing store when the application version changes. This setting is off by default.
11
+ - **Unknown application version:** With the update setting enabled, the first open of an existing store without an application-version marker creates a baseline snapshot. Its source application version is unknown. Later opens with the same version do not repeat it.
12
+ - **Restore:** Before replacing a store, Artifacty creates and verifies a recovery snapshot of its current state.
13
+
14
+ Stop every HTTP server and MCP process that uses the store before an upgrade or restore. This includes services, foreground servers, and local stdio MCP clients. Artifacty checks managed server PID markers, but these markers cannot identify every process with an open store.
15
+
16
+ Snapshots are stored at `ARTIFACTY_HOME/upgrade-backups/<id>/`. The default home is `~/.artifacty`.
17
+
18
+ ## Snapshot Contents
19
+
20
+ Each snapshot contains a manifest with file sizes and SHA-256 hashes. It also contains:
21
+
22
+ - A consistent raw SQLite database, including all tables and security state.
23
+ - Every version file referenced by the database or an authoritative legacy index.
24
+ - The legacy `index.json`, if it exists.
25
+
26
+ Verification checks the file hashes, SQLite integrity, and version-file references. Snapshots do not include unrelated files, process markers, logs, or the complete installation directory. Keep snapshots private: they contain credentials and security records.
27
+
28
+ `ARTIFACTY_UPGRADE_BACKUP_KEEP` controls automatic snapshot retention. The default is `3`; valid values are integers from `1` through `1000`. Artifacty prunes older automatic schema/update snapshots after a successful upgrade. It does not prune manual or recovery snapshots with this setting.
29
+
30
+ ## Inspect Snapshots
31
+
32
+ List snapshots without opening or migrating the store:
33
+
34
+ ```bash
35
+ artifacty backups list --home /path/to/store
36
+ ```
37
+
38
+ Copy an `id` from the output. Verify that snapshot:
39
+
40
+ ```bash
41
+ artifacty backups verify <id> --home /path/to/store
42
+ ```
43
+
44
+ A successful response includes `valid: true`. Listing checks manifests; it does not replace full verification. From a repository checkout, use `node src/cli.js` in place of `artifacty`.
45
+
46
+ ## Restore a Snapshot
47
+
48
+ A restore replaces the current database, version files, and legacy index. Changes made after the snapshot are no longer current. Artifacty keeps the pre-restore state in a separate recovery snapshot.
49
+
50
+ **Security state also rolls back.** Old passwords, permissions, network rules, sessions, API tokens, and share-link records can become current again. A session, token, or link revoked after the snapshot can become valid again if it has not expired. Review the restored security state before allowing users to reconnect.
51
+
52
+ 1. Stop all server, service, and MCP processes that use this store.
53
+ 2. Select a snapshot from `backups list`.
54
+ 3. Verify it with `backups verify`.
55
+ 4. Restore it with explicit confirmation:
56
+
57
+ ```bash
58
+ artifacty backups restore <id> --confirm restore-store --home /path/to/store
59
+ ```
60
+
61
+ 5. Save the returned `recoveryBackupId`.
62
+ 6. Review the restored security settings before reconnecting clients.
63
+
64
+ Restore does not migrate the selected snapshot. Opening it with the current application can trigger another protected migration. A build that does not support the restored schema rejects the store.
65
+
66
+ ## Recover When the Current Store Is Corrupt
67
+
68
+ If Artifacty cannot verify the current store for its recovery snapshot, it returns `RECOVERY_BACKUP_FAILED`. It does not overwrite the damaged store.
69
+
70
+ 1. Keep the damaged home and its snapshots unchanged.
71
+ 2. Create a new empty home directory.
72
+ 3. Copy the selected snapshot directory into the new home's `upgrade-backups/` directory. Preserve its original snapshot ID and directory contents.
73
+ 4. Run `backups verify <id> --home /path/to/new-home`.
74
+ 5. Run `backups restore <id> --confirm restore-store --home /path/to/new-home`.
75
+ 6. Review the restored data and security settings before using the new home.
76
+
77
+ Do not copy the damaged database over the new home. The selected snapshot supplies the database and referenced version files.
78
+
79
+ ## Recover an Interrupted Restore
80
+
81
+ If a `.restore-<uuid>` directory remains, Artifacty stops with `RESTORE_INCOMPLETE`.
82
+
83
+ 1. Preserve that directory and the entire affected home.
84
+ 2. Read `.restore-<uuid>/restore.json`.
85
+ 3. Find its `recoveryBackupId`. This identifies the snapshot of the state before the interrupted restore.
86
+ 4. Copy that recovery snapshot into a new empty home's `upgrade-backups/` directory.
87
+ 5. Verify and restore it there with the commands above.
88
+
89
+ Do not remove the interrupted restore directory to bypass the check. It contains recovery evidence and can contain the previous database and files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -44,6 +44,7 @@
44
44
  "docs/roadmap-design.md",
45
45
  "docs/sarif-csv-artifact-plan.md",
46
46
  "docs/threat-model.md",
47
+ "docs/upgrade-backups.md",
47
48
  "docs/writing-style.md",
48
49
  "scripts/lint.mjs",
49
50
  "scripts/smoke.sh",
@@ -55,7 +56,7 @@
55
56
  "CLAUDE.md"
56
57
  ],
57
58
  "engines": {
58
- "node": ">=22.5"
59
+ "node": ">=22.12"
59
60
  },
60
61
  "license": "MIT",
61
62
  "dependencies": {
@@ -64,7 +65,7 @@
64
65
  "@codemirror/lang-json": "^6.0.2",
65
66
  "@codemirror/lang-markdown": "^6.5.0",
66
67
  "codemirror": "^6.0.2",
67
- "mermaid": "^11.15.0"
68
+ "mermaid": "^12.1.0"
68
69
  },
69
70
  "optionalDependencies": {
70
71
  "@babel/standalone": "^8.0.4",
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { homedir } from "node:os";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { generateToken } from "./lib/token.js";
6
7
  import { parseSseFrames } from "./lib/sse.js";
@@ -32,6 +33,27 @@ async function main() {
32
33
  return;
33
34
  }
34
35
 
36
+ // Recovery must work before loading storage or triggering a schema migration.
37
+ if (command === "backups") {
38
+ const { listUpgradeBackups, verifyUpgradeBackup, restoreUpgradeBackup } = await import("./lib/upgrade-backup.js");
39
+ const store = { home: path.resolve(options.home || process.env.ARTIFACTY_HOME || path.join(homedir(), ".artifacty")) };
40
+ const [action, id, ...extra] = options._;
41
+ if (extra.length || !["list", "verify", "restore"].includes(action) || (action === "list" ? Boolean(id) : !id)) {
42
+ throw new Error("Usage: artifacty backups list | verify <id> | restore <id> --confirm restore-store");
43
+ }
44
+ if (action === "list") {
45
+ printJson({ backups: listUpgradeBackups(store) });
46
+ } else if (action === "verify") {
47
+ printJson(verifyUpgradeBackup(store, id));
48
+ } else {
49
+ if (options.confirm !== "restore-store") {
50
+ throw new Error("Store recovery requires --confirm restore-store. Stop all server and MCP processes before restoring.");
51
+ }
52
+ printJson(restoreUpgradeBackup(store, id, { confirm: options.confirm }));
53
+ }
54
+ return;
55
+ }
56
+
35
57
  const {
36
58
  addComment,
37
59
  addRelation,
@@ -982,6 +1004,9 @@ Usage:
982
1004
  artifacty retention run [--dry-run] [--allow-purge]
983
1005
  artifacty export --file <path> [--full]
984
1006
  artifacty backup [--file <path>] [--full]
1007
+ artifacty backups list [--home <path>]
1008
+ artifacty backups verify <id> [--home <path>]
1009
+ artifacty backups restore <id> --confirm restore-store [--home <path>]
985
1010
  artifacty import-store --file <path> [--confirm replace-all] [--force-users]
986
1011
  artifacty users import --file <path.csv> [--no-password-reset]
987
1012
  artifacty service plist|unit|task|install|uninstall [--platform macos|linux|windows] [--dry-run] [--path <path>] [--mcp-http]
@@ -1000,8 +1025,17 @@ Usage:
1000
1025
  artifacty diff <id> [--from N] [--to M] [--structured] [--json]
1001
1026
  artifacty watch [--tag tag] [--artifact <id>] [--type artifact.updated] [--json] [--once] [--exec "<cmd>"]
1002
1027
 
1028
+ Upgrade recovery:
1029
+ Existing stores receive a mandatory snapshot before a schema migration.
1030
+ Set ARTIFACTY_BACKUP_ON_UPDATE=true to also snapshot before app-version changes.
1031
+ backups list and verify do not migrate the store.
1032
+ Stop all server and MCP processes before backups restore. Restore preserves a recovery snapshot.
1033
+ artifacty restore <id> restores an archived artifact, not a store backup.
1034
+
1003
1035
  Environment:
1004
1036
  ARTIFACTY_HOME Storage directory. Defaults to ~/.artifacty
1037
+ ARTIFACTY_BACKUP_ON_UPDATE Set true for optional app-version snapshots; schema backups are mandatory
1038
+ ARTIFACTY_UPGRADE_BACKUP_KEEP Automatic snapshots to retain after a successful upgrade (default: 3)
1005
1039
  ARTIFACTY_URL Public URL override. Otherwise CLI/MCP read the last running server URL
1006
1040
  ARTIFACTY_HOST Bind host for the HTTP server. Defaults to 127.0.0.1
1007
1041
  ARTIFACTY_PORT Bind port for the HTTP server. Defaults to 8787
package/src/lib/doctor.js CHANGED
@@ -9,7 +9,7 @@ import { securityConfig, validateServerExposure, exposureWarning } from "./secur
9
9
  import { serviceCommand } from "./service.js";
10
10
  import { checkStoreIntegrity, createStore } from "./storage.js";
11
11
 
12
- const MIN_NODE_VERSION = "22.5.0";
12
+ const MIN_NODE_VERSION = "22.12.0";
13
13
 
14
14
  export async function runDoctor(options = {}) {
15
15
  const packageRoot = path.resolve(options.packageRoot || process.cwd());
package/src/lib/render.js CHANGED
@@ -3346,7 +3346,7 @@ function mermaidFrameContent(content) {
3346
3346
  parent.postMessage({ __artifactyHeight: height }, "*");
3347
3347
  }
3348
3348
  try {
3349
- mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
3349
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict", layout: "dagre", theme: "default", look: "classic" });
3350
3350
  const result = await mermaid.render("artifacty-mermaid-svg", source);
3351
3351
  target.innerHTML = result.svg;
3352
3352
  } catch (error) {
@@ -12,7 +12,7 @@ Artifacty keeps agent outputs in one versioned store. You can use the store thro
12
12
 
13
13
  ## Start locally
14
14
 
15
- Use Node.js 22.5 or newer. From the repository, run:
15
+ Use Node.js 22.12.0 or newer. The optional Babel 8 React renderer requires Node 22.18.x or newer within Node 22, or Node 24.11.0 or newer. Use an ES2024-capable browser, such as Safari 17.4 or newer, for Mermaid 12.1.0 diagrams. The classic look, dagre layout, and default theme stay unchanged. From the repository, run:
16
16
 
17
17
  \`\`\`sh
18
18
  npm install
@@ -50,7 +50,13 @@ A share link is a separate read-only capability with an expiry time. It does not
50
50
 
51
51
  \`node src/cli.js samples install\` installs these four manuals once per installation scope. Repeating installation skips active manuals. \`samples remove\` archives managed samples. It preserves their content and versions. Installing again restores them without replacing your edits. Matching titles or tags do not make your own artifacts managed samples.
52
52
 
53
- Back up before maintenance. This build uses store schema 10. Older builds refuse newer stores. This manual describes the implemented repository behavior, not a package release announcement.`
53
+ Back up before maintenance. This build uses store schema 10. Older builds refuse newer stores. This manual describes the implemented repository behavior, not a package release announcement.
54
+
55
+ Before migrating an existing schema, Artifacty creates a mandatory verified snapshot of the raw SQLite database, referenced version files, and legacy index. Set ARTIFACTY_BACKUP_ON_UPDATE=true to also snapshot application-version changes. If the old application version is unknown, the first open creates a baseline snapshot. ARTIFACTY_UPGRADE_BACKUP_KEEP defaults to 3 automatic snapshots and accepts 1–1000.
56
+
57
+ Use \`artifacty backups list\`, \`artifacty backups verify <id>\`, and \`artifacty backups restore <id> --confirm restore-store\`. Stop every server and MCP process first. Restore creates a recovery snapshot before replacing the store. It also restores credentials and security state, so previously revoked sessions, tokens, and links can become valid again. These snapshots differ from portable JSON export backups.
58
+
59
+ If the current store is corrupt, preserve it and copy the selected snapshot to a new empty home before restoring there. If a restore leaves a .restore-* directory, preserve it. Read its restore.json recoveryBackupId and restore that recovery snapshot into a new empty home. See docs/upgrade-backups.md in the repository for the full procedure.`
54
60
  },
55
61
  {
56
62
  key: "feature-guide",
@@ -61,13 +67,13 @@ Back up before maintenance. This build uses store schema 10. Older builds refuse
61
67
  content: `<!doctype html>
62
68
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifacty feature guide</title>
63
69
  <style>body{font-family:system-ui,sans-serif;line-height:1.65;color:#172b3a;background:#f5f7fa;margin:0}main{max-width:900px;margin:auto;padding:40px 24px}h1,h2{line-height:1.2}h1{font-size:2.4rem}section{background:white;border:1px solid #dce4ec;border-radius:12px;padding:24px;margin:20px 0}code{background:#edf2f7;padding:2px 5px;border-radius:4px}li{margin:6px 0}a{color:#175aa3}footer{color:#526475}</style></head><body><main>
64
- <header><p>ARTIFACTY / REFERENCE</p><h1>From an agent output to a shared record</h1><p>Use this inventory to choose the right publishing, review, access, and maintenance workflow. Use Node.js 22.5 or newer. The current store schema is 10.</p></header>
70
+ <header><p>ARTIFACTY / REFERENCE</p><h1>From an agent output to a shared record</h1><p>Use this inventory to choose the right publishing, review, access, and maintenance workflow. Use Node.js 22.12.0 or newer. The current store schema is 10. Mermaid 12.1.0 keeps the classic look, dagre layout, and default theme. Use an ES2024-capable browser, such as Safari 17.4 or newer. The optional Babel 8 React renderer requires Node ^22.18.0 or &gt;=24.11.0.</p></header>
65
71
  <nav aria-label="Guide sections"><a href="#formats">Formats</a> · <a href="#history">History and discovery</a> · <a href="#collaboration">Collaboration</a> · <a href="#access">Access</a> · <a href="#operations">Operations</a></nav>
66
72
  <section id="formats"><h2>Formats and imports</h2><p>The 13 formats are HTML, Markdown, text, JSON, code, SVG, Mermaid, React, SARIF, CSV, image, video, and notebook. Choose an artifact type separately: a document, handoff, code review, test report, dashboard, diagram, component, table, or another supported type.</p><p>Bundles group multiple files under one artifact. Supported binary bundle assets include PDF, DOCX, XLSX, and ZIP. Importers normalize outputs from Claude, Codex, Gemini, GitHub Copilot, Cursor, and generic inputs. Use the browser import screen or <code>artifacty import --agent codex --file handoff.md</code>.</p><p>Artifact content is untrusted. Browser previews use constrained rendering. A public HTML share preview is static. It blocks scripts and external resource loading. Share links show other active formats as source. Do not expect those previews to preserve interactive application behavior.</p></section>
67
73
  <section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
68
74
  <section id="collaboration"><h2>Relationships, comments, review, and notifications</h2><p>Connect artifacts using derived-from, supersedes, reviews, references, or part-of relations. Comments support threads and anchors. After you address a thread, resolve it. Review status progresses among none, pending, changes-requested, and approved.</p><p>The inbox at <code>/inbox</code> collects relevant shares, comments, review activity, and new versions. Notifications depend on the user and current artifact access. You can mark items read individually or together. A notification does not grant access.</p><p>Audit records support investigation. Event polling and server-sent events expose authorized activity streams. Webhooks deliver configured events to validated destinations. Use event filters, replay cursors, and webhook administration for integrations.</p></section>
69
75
  <section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>. Administrators manage accounts at <code>/admin/users</code>. The Access page links to that page. Administrators manage teams, access mode, and network rules at <code>/admin/access</code>. The artifact <strong>Sharing</strong> panel shows effective access and its sources. Direct-user and team grants support read, comment, and edit. Where grants overlap, the strongest permission wins. Ownership and administrator management are separate from grants. Token scopes limit token actions. They do not grant artifact access.</p><p>Private mode requires an active personal account. Server-wide tokens do not authorize ordinary artifact reads or creation in private mode. Administrator APIs have their own authorization rules. Personal tokens remain constrained by both their scopes and the user's artifact permissions. Trusted local CLI operations can administer the local store directly.</p><p>Read-only share links default to seven days and can target the latest version or a pinned version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, owners may link private-visibility artifacts without that site toggle. Links can be revoked, and owner changes invalidate them. A valid capability permits only its read-only shared surface.</p><p>Network allow rules accept IP addresses and CIDRs. First, stage a change. Next, verify access from the intended peer. Then confirm the change within 120 seconds or roll it back. Pending state survives a restart. Before the next access, the server checks for expiry. If the change has expired, the server restores the previous rules. Rules use the socket peer address, not forwarded proxy headers.</p></section>
70
- <section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import backups to move or recover a store. Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
76
+ <section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import JSON backups to move a store. Upgrade snapshots separately preserve raw SQLite, referenced versions, and any legacy index. Schema migrations always require a verified snapshot. Set <code>ARTIFACTY_BACKUP_ON_UPDATE=true</code> for application-version snapshots, including an unknown-version baseline. <code>ARTIFACTY_UPGRADE_BACKUP_KEEP</code> defaults to 3 and accepts 1–1000.</li><li>Use <code>artifacty backups list</code>, <code>artifacty backups verify &lt;id&gt;</code>, and <code>artifacty backups restore &lt;id&gt; --confirm restore-store</code>. Stop every server and MCP process first. Restore saves a recovery snapshot and rolls back credentials and security state. Revoked sessions, tokens, or links can become valid again. For a corrupt store, copy the snapshot to a new empty home and restore there. Preserve interrupted <code>.restore-*</code> directories and use their <code>restore.json</code> recoveryBackupId. See <code>docs/upgrade-backups.md</code>.</li><li>Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
71
77
  <footer><p>This guide is a managed sample artifact. Editing it creates your own version. Reinstalling samples preserves that work.</p></footer>
72
78
  </main></body></html>`
73
79
  },
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, s
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import path from "node:path";
6
- import { DatabaseSync } from "node:sqlite";
6
+ import { openProtectedDatabase } from "./upgrade-backup.js";
7
7
  import { isUnknownSourceAgent, normalizeSourceAgent } from "./agents.js";
8
8
  import { assertNoSecrets, normalizeTokenScopes, securityConfig } from "./security.js";
9
9
  import { EVENT_TYPES, eventFromAudit, eventHistoryLimit, eventVisibleTo, matchesFilter, publish as publishEvent } from "./events.js";
@@ -3630,11 +3630,7 @@ export function normalizeArtifactType(value = "document") {
3630
3630
  // same connection setup (PRAGMAs, schema init/migration) instead of opening
3631
3631
  // a second, differently-configured DatabaseSync against the same file.
3632
3632
  export function openDatabase(store) {
3633
- mkdirSync(store.home, { recursive: true });
3634
- mkdirSync(store.artifactsDir, { recursive: true });
3635
-
3636
- const db = new DatabaseSync(store.dbPath);
3637
- try {
3633
+ return openProtectedDatabase(store, STORE_VERSION, (db) => {
3638
3634
  db.exec(`
3639
3635
  PRAGMA journal_mode = WAL;
3640
3636
  PRAGMA foreign_keys = ON;
@@ -3644,13 +3640,7 @@ export function openDatabase(store) {
3644
3640
  migrateJsonIndex(db, store);
3645
3641
  normalizeStoredSourceAgents(db, store);
3646
3642
  syncSearchIndexIfEmpty(db, store);
3647
- } catch (error) {
3648
- // Never leak an open handle when initialization fails (for example the
3649
- // store_version guard); on Windows an open handle blocks file removal.
3650
- db.close();
3651
- throw error;
3652
- }
3653
- return db;
3643
+ });
3654
3644
  }
3655
3645
 
3656
3646
  function initializeSchema(db) {
@@ -0,0 +1,417 @@
1
+ import { randomUUID, createHash } from "node:crypto";
2
+ import {
3
+ closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync
5
+ } from "node:fs";
6
+ import path from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+
9
+ const APP_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
10
+ const KIND = "artifacty-upgrade-backup";
11
+ const ID_PATTERN = /^(auto|manual|recovery)-[0-9T-]+-[a-f0-9-]{36}$/;
12
+ const wait = new Int32Array(new SharedArrayBuffer(4));
13
+
14
+ function error(message, code = "UPGRADE_BACKUP_FAILED") {
15
+ return Object.assign(new Error(message), { code });
16
+ }
17
+
18
+ function paths(store) {
19
+ const home = path.resolve(store.home);
20
+ return { home, dbPath: path.join(home, "artifacty.sqlite"), indexPath: path.join(home, "index.json"), artifactsDir: path.join(home, "artifacts"), backups: path.join(home, "upgrade-backups") };
21
+ }
22
+
23
+ function safePath(root, relative) {
24
+ if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
25
+ throw error(`Unsafe snapshot path: ${relative}`);
26
+ }
27
+ const target = path.resolve(root, relative);
28
+ if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw error("Snapshot path escapes its root");
29
+ let current = root;
30
+ for (const part of relative.split("/")) {
31
+ current = path.join(current, part);
32
+ try {
33
+ if (lstatSync(current).isSymbolicLink()) throw error(`Snapshot paths must not contain symbolic links: ${current}`);
34
+ } catch (cause) { if (cause.code !== "ENOENT") throw cause; }
35
+ }
36
+ return target;
37
+ }
38
+
39
+ function privateDirectory(directory) {
40
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
41
+ const stat = lstatSync(directory);
42
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw error(`Expected a real directory: ${directory}`);
43
+ chmodSync(directory, 0o700);
44
+ }
45
+
46
+ function alive(pid) {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try { process.kill(pid, 0); return true; }
49
+ catch (cause) { return cause.code !== "ESRCH"; }
50
+ }
51
+
52
+ function assertOffline(store, allowOwnPid = false) {
53
+ for (const filename of ["server.pid", "server.json"]) {
54
+ const file = safePath(store.home, filename);
55
+ if (!existsSync(file)) continue;
56
+ let pid;
57
+ try {
58
+ const text = readFileSync(file, "utf8");
59
+ pid = filename.endsWith(".json") ? JSON.parse(text).pid : Number(text.trim());
60
+ } catch { throw error(`Cannot inspect ${filename}; stop the server and repair this marker first`, "STORE_NOT_OFFLINE"); }
61
+ if (alive(pid) && !(allowOwnPid && pid === process.pid)) throw error("Stop the managed Artifacty server before upgrading or restoring this store", "STORE_NOT_OFFLINE");
62
+ }
63
+ }
64
+
65
+ function durableWrite(file, content) {
66
+ const fd = openSync(file, "wx", 0o600);
67
+ try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
68
+ }
69
+
70
+ function syncDirectory(directory) {
71
+ if (process.platform === "win32") return;
72
+ const fd = openSync(directory, "r");
73
+ try { fsyncSync(fd); } finally { closeSync(fd); }
74
+ }
75
+
76
+ function syncTree(directory) {
77
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
78
+ const file = path.join(directory, entry.name);
79
+ if (entry.isDirectory()) syncTree(file);
80
+ else {
81
+ const fd = openSync(file, "r+");
82
+ try { fsyncSync(fd); } finally { closeSync(fd); }
83
+ }
84
+ }
85
+ syncDirectory(directory);
86
+ }
87
+
88
+ // A separate reaper guard serializes removal of dead-owner locks. A malformed
89
+ // lock or an abandoned reaper fails closed; it is never guessed to be stale.
90
+ function lockStore(store) {
91
+ privateDirectory(store.home);
92
+ const lock = safePath(store.home, ".upgrade.lock");
93
+ const reaper = safePath(store.home, ".upgrade-reaper");
94
+ const token = randomUUID();
95
+ const deadline = Date.now() + 5000;
96
+ const acquire = () => {
97
+ durableWrite(lock, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }));
98
+ return () => {
99
+ const current = JSON.parse(readFileSync(lock, "utf8"));
100
+ if (current.token !== token) throw error("Store lock ownership changed");
101
+ rmSync(lock);
102
+ };
103
+ };
104
+ while (true) {
105
+ if (!existsSync(reaper)) {
106
+ try { return acquire(); } catch (cause) { if (cause.code !== "EEXIST") throw cause; }
107
+ let owner;
108
+ try { owner = JSON.parse(readFileSync(lock, "utf8")); }
109
+ catch (cause) {
110
+ if (cause.code === "ENOENT") continue;
111
+ if (Date.now() >= deadline) throw error("Unreadable upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
112
+ Atomics.wait(wait, 0, 0, 25);
113
+ continue;
114
+ }
115
+ if (!Number.isInteger(owner.pid) || !owner.token) throw error("Invalid upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
116
+ if (!alive(owner.pid)) {
117
+ try { mkdirSync(reaper, { mode: 0o700 }); }
118
+ catch (cause) { if (cause.code === "EEXIST") continue; throw cause; }
119
+ try {
120
+ if (existsSync(lock)) {
121
+ const current = JSON.parse(readFileSync(lock, "utf8"));
122
+ if (current.token === owner.token && !alive(current.pid)) rmSync(lock);
123
+ }
124
+ if (!existsSync(lock)) return acquire();
125
+ } finally { rmSync(reaper, { recursive: true }); }
126
+ }
127
+ }
128
+ if (Date.now() >= deadline) throw error("Store upgrade or restore is already running; inspect .upgrade.lock and .upgrade-reaper if its owner has stopped", "STORE_LOCKED");
129
+ Atomics.wait(wait, 0, 0, 25);
130
+ }
131
+ }
132
+
133
+ function rawState(store) {
134
+ safePath(store.home, "artifacty.sqlite");
135
+ safePath(store.home, "index.json");
136
+ if (!existsSync(store.dbPath) || lstatSync(store.dbPath).size === 0) return { schemaVersion: null, appVersion: null, hasDatabase: false, hasData: existsSync(store.indexPath) };
137
+ safePath(store.home, "artifacty.sqlite");
138
+ const db = new DatabaseSync(store.dbPath, { readOnly: true });
139
+ try {
140
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
141
+ const hasMeta = tables.some((table) => table.name === "meta");
142
+ const schema = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='store_version'").get()?.value : null;
143
+ const app = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='application_version'").get()?.value : null;
144
+ if (schema != null && (!/^\d+$/.test(String(schema)) || !Number.isSafeInteger(Number(schema)))) throw error("Invalid stored schema version");
145
+ return { schemaVersion: schema == null ? null : Number(schema), appVersion: app || null, hasDatabase: true, hasData: tables.length > 0 || existsSync(store.indexPath) };
146
+ } finally { db.close(); }
147
+ }
148
+
149
+ function fileInfo(file) {
150
+ const stat = lstatSync(file);
151
+ if (!stat.isFile() || stat.isSymbolicLink()) throw error(`Expected a regular file: ${file}`);
152
+ return { sizeBytes: stat.size, sha256: createHash("sha256").update(readFileSync(file)).digest("hex") };
153
+ }
154
+
155
+ function references(database, indexFile) {
156
+ const refs = new Map();
157
+ const add = (row) => {
158
+ const versionPath = typeof row.path === "string" ? row.path.replaceAll("\\", "/") : "";
159
+ if (!versionPath.startsWith("artifacts/")) throw error("Version path must be inside artifacts/");
160
+ const expected = { path: versionPath, sizeBytes: row.size_bytes ?? row.sizeBytes, sha256: row.sha256 };
161
+ if (!Number.isSafeInteger(expected.sizeBytes) || expected.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(expected.sha256 || "")) throw error(`Invalid version size or hash: ${row.path}`);
162
+ const previous = refs.get(versionPath);
163
+ if (previous && (previous.sha256 !== expected.sha256 || previous.sizeBytes !== expected.sizeBytes)) throw error("Conflicting version file references");
164
+ refs.set(versionPath, expected);
165
+ };
166
+ let legacyAlreadyImported = false;
167
+ if (database) {
168
+ const db = new DatabaseSync(database, { readOnly: true });
169
+ try {
170
+ const result = db.prepare("PRAGMA integrity_check").all();
171
+ if (result.length !== 1 || Object.values(result[0])[0] !== "ok") throw error("Snapshot SQLite integrity check failed");
172
+ if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifact_versions'").get()) {
173
+ for (const row of db.prepare("SELECT path, size_bytes, sha256 FROM artifact_versions").all()) add(row);
174
+ }
175
+ if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifacts'").get()) {
176
+ legacyAlreadyImported = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count > 0;
177
+ }
178
+ if (db.prepare("SELECT name FROM sqlite_master WHERE name='meta'").get()) {
179
+ legacyAlreadyImported ||= db.prepare("SELECT value FROM meta WHERE key='json_index_migrated'").get()?.value === "true";
180
+ }
181
+ } finally { db.close(); }
182
+ }
183
+ // A legacy index is authoritative only before its import into SQLite.
184
+ if (indexFile && !legacyAlreadyImported) {
185
+ const index = JSON.parse(readFileSync(indexFile, "utf8"));
186
+ if (!Array.isArray(index.artifacts)) throw error("Invalid legacy index");
187
+ for (const artifact of index.artifacts) for (const version of artifact.versions || []) add(version);
188
+ }
189
+ return [...refs.values()].sort((a, b) => a.path.localeCompare(b.path));
190
+ }
191
+
192
+ function compareFile(root, expected) {
193
+ const actual = fileInfo(safePath(root, expected.path));
194
+ if (actual.sizeBytes !== expected.sizeBytes || actual.sha256 !== expected.sha256) throw error(`Snapshot file verification failed: ${expected.path}`);
195
+ }
196
+
197
+ function snapshotDirectory(store, id) {
198
+ if (!ID_PATTERN.test(id || "")) throw error("Invalid snapshot id");
199
+ return safePath(store.home, `upgrade-backups/${id}`);
200
+ }
201
+
202
+ function manifestAt(store, id) {
203
+ const directory = snapshotDirectory(store, id);
204
+ const manifest = JSON.parse(readFileSync(safePath(directory, "manifest.json"), "utf8"));
205
+ if (manifest.kind !== KIND || manifest.manifestVersion !== 1 || manifest.id !== id || !Array.isArray(manifest.files)) throw error("Invalid snapshot manifest");
206
+ const seen = new Set();
207
+ for (const file of manifest.files) {
208
+ if (!file || (file.path !== "artifacty.sqlite" && file.path !== "index.json" && !file.path?.startsWith("artifacts/")) || seen.has(file.path)) throw error("Invalid snapshot file entry");
209
+ safePath(directory, file.path);
210
+ if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(file.sha256 || "")) throw error("Invalid snapshot file hash or size");
211
+ seen.add(file.path);
212
+ }
213
+ return { directory, manifest };
214
+ }
215
+
216
+ export function verifyUpgradeBackup(input, id) {
217
+ const store = paths(input);
218
+ const { directory, manifest } = manifestAt(store, id);
219
+ for (const file of manifest.files) compareFile(directory, file);
220
+ const database = manifest.files.some((file) => file.path === "artifacty.sqlite") ? safePath(directory, "artifacty.sqlite") : null;
221
+ const index = manifest.files.some((file) => file.path === "index.json") ? safePath(directory, "index.json") : null;
222
+ if (!database && !index && !manifest.emptyStore) throw error("Snapshot has no database or legacy index");
223
+ const refs = references(database, index);
224
+ const listed = manifest.files.filter((file) => file.path.startsWith("artifacts/"));
225
+ if (JSON.stringify(refs) !== JSON.stringify(listed)) throw error("Snapshot manifest does not match database file references");
226
+ return { id, valid: true, filesVerified: manifest.files.length, ...manifest, path: directory };
227
+ }
228
+
229
+ export function listUpgradeBackups(input) {
230
+ const store = paths(input);
231
+ safePath(store.home, "upgrade-backups");
232
+ if (!existsSync(store.backups)) return [];
233
+ return readdirSync(store.backups).filter((id) => ID_PATTERN.test(id)).map((id) => {
234
+ try { const { directory, manifest } = manifestAt(store, id); return { ...manifest, path: directory }; }
235
+ catch (cause) { return { id, valid: false, error: cause.message }; }
236
+ }).sort((a, b) => String(b.createdAt || b.id).localeCompare(String(a.createdAt || a.id)));
237
+ }
238
+
239
+ function retentionCount() {
240
+ const value = process.env.ARTIFACTY_UPGRADE_BACKUP_KEEP || "3";
241
+ if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1000) throw error("ARTIFACTY_UPGRADE_BACKUP_KEEP must be from 1 to 1000");
242
+ return Number(value);
243
+ }
244
+
245
+ function pruneAutomatic(store) {
246
+ const keep = retentionCount();
247
+ const automatic = listUpgradeBackups(store).filter((item) => item.id.startsWith("auto-") && item.kind === KIND && ["schema-upgrade", "application-update"].includes(item.reason));
248
+ for (const item of automatic.slice(keep)) {
249
+ verifyUpgradeBackup(store, item.id);
250
+ rmSync(snapshotDirectory(store, item.id), { recursive: true });
251
+ }
252
+ }
253
+
254
+ function createSnapshot(store, options = {}) {
255
+ const state = rawState(store);
256
+ const reason = options.reason || "manual";
257
+ const prefix = reason === "recovery" ? "recovery" : ["schema-upgrade", "application-update"].includes(reason) ? "auto" : "manual";
258
+ const id = `${prefix}-${new Date().toISOString().replace(/[:.Z]/g, "-")}-${randomUUID()}`;
259
+ privateDirectory(safePath(store.home, "upgrade-backups"));
260
+ const pending = safePath(store.backups, `.pending-${randomUUID()}`);
261
+ privateDirectory(pending);
262
+ let guard;
263
+ let published;
264
+ let complete = false;
265
+ try {
266
+ const files = [];
267
+ if (state.hasDatabase) {
268
+ guard = new DatabaseSync(store.dbPath);
269
+ guard.exec("PRAGMA busy_timeout=5000; BEGIN IMMEDIATE");
270
+ const source = new DatabaseSync(store.dbPath, { readOnly: true });
271
+ try { source.prepare("VACUUM INTO ?").run(path.join(pending, "artifacty.sqlite")); }
272
+ finally { source.close(); }
273
+ chmodSync(path.join(pending, "artifacty.sqlite"), 0o600);
274
+ files.push({ path: "artifacty.sqlite", ...fileInfo(path.join(pending, "artifacty.sqlite")) });
275
+ }
276
+ if (existsSync(store.indexPath)) {
277
+ const original = safePath(store.home, "index.json");
278
+ copyFileSync(original, path.join(pending, "index.json"));
279
+ chmodSync(path.join(pending, "index.json"), 0o600);
280
+ files.push({ path: "index.json", ...fileInfo(path.join(pending, "index.json")) });
281
+ }
282
+ const refs = references(state.hasDatabase ? path.join(pending, "artifacty.sqlite") : null, existsSync(path.join(pending, "index.json")) ? path.join(pending, "index.json") : null);
283
+ for (const ref of refs) {
284
+ const source = safePath(store.home, ref.path);
285
+ const target = safePath(pending, ref.path);
286
+ privateDirectory(path.dirname(target));
287
+ copyFileSync(source, target);
288
+ chmodSync(target, 0o600);
289
+ compareFile(pending, ref);
290
+ files.push(ref);
291
+ }
292
+ if (prefix === "auto") {
293
+ const previous = listUpgradeBackups(store).find((item) => item.reason === reason
294
+ && item.sourceSchemaVersion === state.schemaVersion && item.targetSchemaVersion === (options.targetSchemaVersion ?? null)
295
+ && item.sourceAppVersion === state.appVersion && item.targetAppVersion === (options.targetAppVersion ?? null)
296
+ && JSON.stringify(item.files) === JSON.stringify(files));
297
+ if (previous) {
298
+ try { return verifyUpgradeBackup(store, previous.id); } catch { /* Keep damaged backups for inspection; create a verified replacement. */ }
299
+ }
300
+ }
301
+ const manifest = { kind: KIND, manifestVersion: 1, id, reason, createdAt: new Date().toISOString(), sourceSchemaVersion: state.schemaVersion, targetSchemaVersion: options.targetSchemaVersion ?? null, sourceAppVersion: state.appVersion, targetAppVersion: options.targetAppVersion ?? null, emptyStore: !state.hasData && !state.hasDatabase, files };
302
+ durableWrite(path.join(pending, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
303
+ syncTree(pending);
304
+ const destination = snapshotDirectory(store, id);
305
+ renameSync(pending, destination);
306
+ published = destination;
307
+ syncDirectory(store.backups);
308
+ const verified = verifyUpgradeBackup(store, id);
309
+ complete = true;
310
+ return verified;
311
+ } finally {
312
+ if (guard) { try { guard.exec("ROLLBACK"); } finally { guard.close(); } }
313
+ if (existsSync(pending)) rmSync(pending, { recursive: true, force: true });
314
+ if (published && !complete) rmSync(published, { recursive: true, force: true });
315
+ }
316
+ }
317
+
318
+ function assertNoInterruptedRestore(store) {
319
+ const pending = readdirSync(store.home).find((name) => /^\.restore-[a-f0-9-]{36}$/.test(name));
320
+ if (pending) throw error(`Interrupted restore found at ${path.join(store.home, pending)}. Preserve this directory and the damaged store. Use its restore.json recoveryBackupId to restore into a new empty home.`, "RESTORE_INCOMPLETE");
321
+ }
322
+
323
+ export function createUpgradeBackup(input, options = {}) {
324
+ const store = paths(input);
325
+ const release = lockStore(store);
326
+ try { assertNoInterruptedRestore(store); assertOffline(store); return createSnapshot(store, options); }
327
+ finally { release(); }
328
+ }
329
+
330
+ // The lock spans the snapshot, migrations, and application-version marker.
331
+ // Existing application handles must be stopped separately before an upgrade.
332
+ export function openProtectedDatabase(input, targetVersion, initialize) {
333
+ const store = paths(input);
334
+ const release = lockStore(store);
335
+ let db;
336
+ try {
337
+ assertNoInterruptedRestore(store);
338
+ const state = rawState(store);
339
+ if (state.schemaVersion > targetVersion) throw error(`Artifacty store was written by a newer version (store_version ${state.schemaVersion}) than this build supports (STORE_VERSION ${targetVersion}). Upgrade artifacty before opening this store.`, "NEWER_STORE_VERSION");
340
+ const schemaUpgrade = state.hasData && (state.schemaVersion == null || state.schemaVersion < targetVersion);
341
+ const appUpdate = state.hasData && process.env.ARTIFACTY_BACKUP_ON_UPDATE === "true" && state.appVersion !== APP_VERSION;
342
+ if (schemaUpgrade || appUpdate) {
343
+ retentionCount();
344
+ assertOffline(store, true);
345
+ createSnapshot(store, { reason: schemaUpgrade ? "schema-upgrade" : "application-update", targetSchemaVersion: targetVersion, targetAppVersion: APP_VERSION });
346
+ }
347
+ privateDirectory(store.artifactsDir);
348
+ db = new DatabaseSync(store.dbPath);
349
+ initialize(db);
350
+ if (state.appVersion !== APP_VERSION) db.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES ('application_version', ?)").run(APP_VERSION);
351
+ if (schemaUpgrade || appUpdate) pruneAutomatic(store);
352
+ return db;
353
+ } catch (cause) { db?.close(); throw cause; }
354
+ finally { release(); }
355
+ }
356
+
357
+ export function restoreUpgradeBackup(input, id, { confirm } = {}) {
358
+ if (confirm !== "restore-store") throw error('Restore requires confirm: "restore-store"', "RESTORE_CONFIRMATION_REQUIRED");
359
+ const store = paths(input);
360
+ const release = lockStore(store);
361
+ let stage;
362
+ let preserveStage = false;
363
+ try {
364
+ assertNoInterruptedRestore(store);
365
+ assertOffline(store);
366
+ const snapshot = verifyUpgradeBackup(store, id);
367
+ let recovery;
368
+ try { recovery = createSnapshot(store, { reason: "recovery" }); }
369
+ catch (cause) {
370
+ throw error(`Cannot verify the current store for a recovery snapshot: ${cause.message}. Preserve the damaged store and copy the selected snapshot into a new empty home's upgrade-backups directory, then restore there.`, "RECOVERY_BACKUP_FAILED");
371
+ }
372
+ stage = safePath(store.home, `.restore-${randomUUID()}`);
373
+ privateDirectory(stage);
374
+ privateDirectory(path.join(stage, "artifacts"));
375
+ for (const file of snapshot.files) {
376
+ const target = safePath(stage, file.path);
377
+ privateDirectory(path.dirname(target));
378
+ copyFileSync(safePath(snapshot.path, file.path), target);
379
+ chmodSync(target, 0o600);
380
+ compareFile(stage, file);
381
+ }
382
+ const previous = path.join(stage, "previous");
383
+ privateDirectory(previous);
384
+ const names = ["artifacty.sqlite", "artifacty.sqlite-wal", "artifacty.sqlite-shm", "index.json", "artifacts"];
385
+ const moved = [];
386
+ const installed = [];
387
+ durableWrite(path.join(stage, "restore.json"), JSON.stringify({ snapshotId: id, recoveryBackupId: recovery.id, createdAt: new Date().toISOString() }));
388
+ syncTree(stage);
389
+ syncDirectory(store.home);
390
+ try {
391
+ for (const name of names) {
392
+ const current = safePath(store.home, name);
393
+ if (existsSync(current)) { renameSync(current, path.join(previous, name)); moved.push(name); }
394
+ }
395
+ for (const name of ["artifacty.sqlite", "index.json", "artifacts"]) {
396
+ const source = path.join(stage, name);
397
+ if (existsSync(source)) { renameSync(source, path.join(store.home, name)); installed.push(name); }
398
+ }
399
+ syncDirectory(previous);
400
+ syncDirectory(store.home);
401
+ } catch (cause) {
402
+ try {
403
+ for (const name of installed.reverse()) rmSync(path.join(store.home, name), { recursive: true, force: true });
404
+ for (const name of moved.reverse()) renameSync(path.join(previous, name), path.join(store.home, name));
405
+ syncDirectory(store.home);
406
+ } catch (rollbackError) {
407
+ preserveStage = true;
408
+ throw error(`Restore and rollback failed. Preserve ${stage}; recovery snapshot ${recovery.id}. Original error: ${cause.message}; rollback error: ${rollbackError.message}`, "RESTORE_INCOMPLETE");
409
+ }
410
+ throw cause;
411
+ }
412
+ return { restored: id, recoveryBackupId: recovery.id };
413
+ } finally {
414
+ if (stage && !preserveStage && existsSync(stage)) rmSync(stage, { recursive: true, force: true });
415
+ release();
416
+ }
417
+ }
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  deleteComment,
25
25
  deleteSavedView,
26
26
  deleteWebhook,
27
+ ensureStore,
27
28
  getArtifact,
28
29
  getAccessMode,
29
30
  canReadArtifactEvent,
@@ -192,6 +193,9 @@ export async function startServer(options = {}) {
192
193
  const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
193
194
  validateServerExposure({ host, config: security });
194
195
 
196
+ // Finish protected store upgrades before accepting requests or recording a live server.
197
+ await ensureStore(store);
198
+
195
199
  const { server, actualPort } = await listenWithFallback({
196
200
  host,
197
201
  requestedPort,