superdev-cli 0.1.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.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,189 @@
1
+ // The only place `connect()` is called.
2
+ //
3
+ // The engine takes an exclusive process-level lock for a connection's whole
4
+ // lifetime and `connect()` fails immediately rather than waiting, so this module
5
+ // exposes exactly two entry points and never hands a live write connection to a
6
+ // caller: `read` (lock-free) and `write` (open, one transaction, close).
7
+ //
8
+ // See ADR-0014 for the measurements this design is built on.
9
+
10
+ import { connect } from "@tursodatabase/database";
11
+ import { mkdirSync } from "node:fs";
12
+ import { dirname } from "node:path";
13
+
14
+ export const E = {
15
+ LOCKED: "E_DB_LOCKED",
16
+ VERSION_CONFLICT: "E_VERSION_CONFLICT",
17
+ ENGINE_MISSING: "E_ENGINE_MISSING",
18
+ };
19
+
20
+ export class DbError extends Error {
21
+ constructor(code, message, detail) {
22
+ super(message);
23
+ this.name = "DbError";
24
+ this.code = code;
25
+ if (detail !== undefined) this.detail = detail;
26
+ }
27
+ }
28
+
29
+ // Contention does not always say "lock". When this process already holds a
30
+ // readonly connection to the same file, the engine refuses the write with
31
+ // "Resource is read-only" at BEGIN IMMEDIATE rather than at open. That is a
32
+ // transient collision, not a permission fault, so it is retried like a lock.
33
+ const LOCK_RE = /lock|resource is read-?only|database is busy/i;
34
+ const isLockError = (err) => LOCK_RE.test(String(err?.message ?? ""));
35
+
36
+ // Bounded retry with jitter. The engine fails fast at 0 ms, so without this a
37
+ // write would lose any race instead of waiting out a 3 ms transaction.
38
+ const RETRY_ATTEMPTS = 60;
39
+ const RETRY_BASE_MS = 8;
40
+ const RETRY_MAX_MS = 60;
41
+
42
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
+
44
+ /**
45
+ * Open for writing, retrying while the file is contended.
46
+ *
47
+ * `begin` opens the transaction inside the retry, because the engine can accept
48
+ * the connection and only then refuse BEGIN IMMEDIATE. Retrying is safe: the
49
+ * refusal lands before any statement of the transaction has run.
50
+ */
51
+ async function openExclusive(file, { begin = false } = {}) {
52
+ let last;
53
+ for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
54
+ let db;
55
+ try {
56
+ db = await connect(file);
57
+ // Foreign key enforcement is per connection, so it is re-armed every time.
58
+ for (const p of SESSION_PRAGMAS) await db.exec(p);
59
+ if (begin) await db.exec("BEGIN IMMEDIATE");
60
+ return db;
61
+ } catch (err) {
62
+ if (db) await db.close().catch(() => {});
63
+ if (!isLockError(err)) throw err;
64
+ last = err;
65
+ const backoff = Math.min(RETRY_BASE_MS * 2 ** Math.floor(attempt / 6), RETRY_MAX_MS);
66
+ await sleep(backoff / 2 + Math.random() * backoff);
67
+ }
68
+ }
69
+ throw new DbError(
70
+ E.LOCKED,
71
+ "The project database is held by another Superdev process. Wait a moment and run the command again.",
72
+ String(last?.message ?? ""),
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Read the database without taking the write lock.
78
+ *
79
+ * The option name is lower-case `readonly`. `readOnly` is silently accepted by
80
+ * the engine and takes the *write* path, which blocks. Asserted, not trusted.
81
+ *
82
+ * DO NOT CACHE OR POOL THE CONNECTION THIS OPENS.
83
+ *
84
+ * A readonly connection is pinned to the snapshot it saw when it opened. It
85
+ * never observes another process's later commits: measured, a long-lived
86
+ * readonly handle reported zero rows while a fresh one on the same file
87
+ * reported two. Holding one open across requests would serve permanently stale
88
+ * data and the staleness would be invisible, because every query still
89
+ * succeeds. Opening per read costs 0.3 ms, which is why this is affordable and
90
+ * why "optimizing" it into a pool is the one change that must never be made.
91
+ */
92
+ export async function read(file, fn) {
93
+ const db = await connect(file, { readonly: true });
94
+ try {
95
+ return await fn(wrap(db, file));
96
+ } finally {
97
+ await db.close().catch(() => {});
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Run one transaction and close. The lock is held for the duration of `fn` only,
103
+ * so `fn` must not await anything unrelated to the database.
104
+ */
105
+ export async function write(file, fn) {
106
+ mkdirSync(dirname(file), { recursive: true });
107
+ const db = await openExclusive(file, { begin: true });
108
+ const api = wrap(db, file);
109
+ let began = true;
110
+ try {
111
+ const result = await fn(api);
112
+ await db.exec("COMMIT");
113
+ began = false;
114
+ return result;
115
+ } catch (err) {
116
+ if (began) await db.exec("ROLLBACK").catch(() => {});
117
+ throw err;
118
+ } finally {
119
+ await db.close().catch(() => {});
120
+ }
121
+ }
122
+
123
+ /** Open exclusively without a transaction. For migrations and maintenance only. */
124
+ export async function writeRaw(file, fn) {
125
+ mkdirSync(dirname(file), { recursive: true });
126
+ const db = await openExclusive(file);
127
+ try {
128
+ return await fn(wrap(db, file));
129
+ } finally {
130
+ await db.close().catch(() => {});
131
+ }
132
+ }
133
+
134
+ function wrap(db, file) {
135
+ return {
136
+ raw: db,
137
+ /** Which database this handle is on, so caches keyed by table stay correct. */
138
+ file,
139
+ exec: (sql) => db.exec(sql),
140
+ all: (sql, ...params) => db.prepare(sql).all(...params),
141
+ get: (sql, ...params) => db.prepare(sql).get(...params),
142
+ run: (sql, ...params) => db.prepare(sql).run(...params),
143
+ /** A single scalar from the first column of the first row, or undefined. */
144
+ async value(sql, ...params) {
145
+ const row = await db.prepare(sql).get(...params);
146
+ return row === undefined ? undefined : Object.values(row)[0];
147
+ },
148
+ /**
149
+ * Update guarded by an optimistic version. Returns the new version.
150
+ * A zero-row result means someone else moved the record, which is a
151
+ * conflict and never a silent no-op.
152
+ */
153
+ async versionedUpdate(table, id, expectedVersion, assignments) {
154
+ const sets = Object.keys(assignments).map((c) => `${c} = ?`).join(", ");
155
+ const values = Object.values(assignments);
156
+ const info = await db
157
+ .prepare(
158
+ `UPDATE ${table} SET ${sets}, version = version + 1
159
+ WHERE id = ? AND version = ?`,
160
+ )
161
+ .run(...values, id, expectedVersion);
162
+ if (!info.changes) {
163
+ const current = await db.prepare(`SELECT version FROM ${table} WHERE id = ?`).get(id);
164
+ throw new DbError(
165
+ E.VERSION_CONFLICT,
166
+ current === undefined
167
+ ? `${table} ${id} does not exist.`
168
+ : `${table} ${id} changed underneath this edit. It is now at version ${current.version}, this edit expected ${expectedVersion}.`,
169
+ { table, id, expected: expectedVersion, actual: current?.version ?? null },
170
+ );
171
+ }
172
+ return expectedVersion + 1;
173
+ },
174
+ };
175
+ }
176
+
177
+ /** Pragmas applied to a freshly created database. `mvcc` persists in the header. */
178
+ export const CREATE_PRAGMAS = [
179
+ "PRAGMA journal_mode = mvcc",
180
+ "PRAGMA foreign_keys = ON",
181
+ "PRAGMA busy_timeout = 5000",
182
+ ];
183
+
184
+ /** Pragmas re-applied on every connection. `foreign_keys` is per-connection. */
185
+ export const SESSION_PRAGMAS = ["PRAGMA foreign_keys = ON", "PRAGMA busy_timeout = 5000"];
186
+
187
+ export async function applySessionPragmas(api) {
188
+ for (const p of SESSION_PRAGMAS) await api.exec(p);
189
+ }
@@ -0,0 +1,419 @@
1
+ // Database maintenance: integrity, backups, restore, and portable export.
2
+ //
3
+ // Two engine facts shape this whole file, both measured against the bundled
4
+ // engine rather than assumed from SQLite:
5
+ //
6
+ // 1. With `journal_mode = mvcc` the committed data lives in the sidecar
7
+ // `superdev.db-log` until a checkpoint. A freshly migrated project has an
8
+ // 8 KB `superdev.db` and a 90 KB log, so copying the main file alone
9
+ // produces a backup with no data in it. Backups are taken with
10
+ // `VACUUM INTO`, which writes one self-contained file under the exclusive
11
+ // lock, so it can neither miss the log nor tear against a live writer.
12
+ // 2. The engine refuses to open a database whose sidecar files are missing,
13
+ // even when they would be empty. Restoring therefore recreates them empty
14
+ // rather than leaving the replaced database's log in place.
15
+ //
16
+ // Import and hydrate write rows with plain SQL instead of the repository
17
+ // helpers. That is deliberate and is the one place it is correct: restoring a
18
+ // snapshot must reproduce the exact ids, versions, hash chain and timestamps
19
+ // that were exported. Routing it through `create` would mint new ids and append
20
+ // a second set of activity events, which is the opposite of a restore.
21
+
22
+ import {
23
+ copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync,
24
+ rmSync, statSync, writeFileSync,
25
+ } from "node:fs";
26
+ import { basename, dirname, join, resolve } from "node:path";
27
+ import { paths, ensureDatabase, query, mutate, currentProject, DbError } from "./store.mjs";
28
+ import { writeRaw } from "./connect.mjs";
29
+ import { inspect } from "./migrate.mjs";
30
+ import { assertNoLiveService } from "../service/manage.mjs";
31
+ import { slugify } from "../model/ids.mjs";
32
+
33
+ export const E = {
34
+ NO_DATABASE: "E_NO_DATABASE",
35
+ NO_BACKUP: "E_NO_BACKUP",
36
+ BAD_EXPORT: "E_BAD_EXPORT",
37
+ PROJECT_MISMATCH: "E_PROJECT_MISMATCH",
38
+ SCHEMA_AHEAD: "E_SCHEMA_AHEAD",
39
+ NOT_EMPTY: "E_NOT_EMPTY",
40
+ };
41
+
42
+ /** Format tag on every export header. A reader that does not know it refuses. */
43
+ export const EXPORT_FORMAT = "superdev-export-v1";
44
+
45
+ /** How many backups survive. Older ones are deleted as new ones are taken. */
46
+ export const KEEP_BACKUPS = 10;
47
+
48
+ // Files the engine keeps beside the database. Recreated empty on restore.
49
+ const SIDECARS = ["-log", "-wal"];
50
+
51
+ const stamp = (iso) => iso.replace(/[:.]/g, "-");
52
+
53
+ const missingDatabase = () =>
54
+ new DbError(E.NO_DATABASE, "This project has no Superdev database yet. Run the init command first.");
55
+
56
+ /**
57
+ * User tables in creation order.
58
+ *
59
+ * Creation order, not alphabetical: it is just as deterministic and it puts
60
+ * parents before children, so a straight replay of the export satisfies the
61
+ * foreign keys. `applied_migrations` is included on purpose, so a hydrated
62
+ * database knows which schema version it is at.
63
+ */
64
+ async function tableNames(db) {
65
+ const rows = await db.all("SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY rowid");
66
+ return rows.map((r) => r.name).filter((n) => !internal(n));
67
+ }
68
+
69
+ const internal = (name) =>
70
+ name.startsWith("sqlite_") || name.startsWith("__") || name.toLowerCase().includes("turso_internal");
71
+
72
+ // ------------------------------------------------------------------ integrity
73
+
74
+ /**
75
+ * Structural health of the project database. Run before status output and
76
+ * before anything that rewrites the file.
77
+ *
78
+ * `ok` covers what silently corrupts a project: page corruption, dangling
79
+ * foreign keys, and migration drift. A pending migration is reported under
80
+ * `migrations` but does not make the database unhealthy, it makes it behind.
81
+ */
82
+ export async function integrityCheck(root) {
83
+ const p = paths(root);
84
+ if (!existsSync(p.db)) throw missingDatabase();
85
+ const migrations = await inspect(p.db);
86
+
87
+ return query(root, async (db) => {
88
+ const integrity = (await db.all("PRAGMA integrity_check")).map((row) => String(Object.values(row)[0]));
89
+ const foreignKeys = await db.all("PRAGMA foreign_key_check");
90
+ const counts = {};
91
+ for (const table of await tableNames(db)) {
92
+ counts[table] = Number(await db.value(`SELECT count(*) FROM ${table}`));
93
+ }
94
+ const ok =
95
+ integrity.length === 1 && integrity[0] === "ok" &&
96
+ foreignKeys.length === 0 &&
97
+ migrations.drift.length === 0;
98
+ return { ok, integrity, foreignKeys, migrations, counts };
99
+ });
100
+ }
101
+
102
+ // -------------------------------------------------------------------- backups
103
+
104
+ /**
105
+ * Snapshot the database into `.superdev/backups/` and prune to the newest ten.
106
+ * Cheap enough to run at session handoff and before every destructive command.
107
+ */
108
+ export async function backup(root, label = "manual") {
109
+ const p = paths(root);
110
+ if (!existsSync(p.db)) throw missingDatabase();
111
+ mkdirSync(p.backups, { recursive: true });
112
+
113
+ const at = new Date().toISOString();
114
+ const path = join(p.backups, `${slugify(label, "backup")}-${stamp(at)}.db`);
115
+ rmSync(path, { force: true }); // VACUUM INTO refuses to write over a file
116
+
117
+ await writeRaw(p.db, (db) => db.exec(`VACUUM INTO '${path.replace(/'/g, "''")}'`));
118
+
119
+ for (const old of (await listBackups(root)).slice(KEEP_BACKUPS)) rmSync(old.path, { force: true });
120
+ return { path, bytes: statSync(path).size, at };
121
+ }
122
+
123
+ /** Backups newest first. The name stamp breaks ties within the same second. */
124
+ export async function listBackups(root) {
125
+ const dir = paths(root).backups;
126
+ if (!existsSync(dir)) return [];
127
+ return readdirSync(dir)
128
+ .filter((name) => name.endsWith(".db"))
129
+ .map((name) => {
130
+ const path = join(dir, name);
131
+ const info = statSync(path);
132
+ return {
133
+ name,
134
+ path,
135
+ label: name.replace(/-\d{4}-\d{2}-\d{2}T.*$/, ""),
136
+ bytes: info.size,
137
+ at: info.mtime.toISOString(),
138
+ };
139
+ })
140
+ .sort((a, b) => (a.at === b.at ? b.name.localeCompare(a.name) : b.at.localeCompare(a.at)));
141
+ }
142
+
143
+ /**
144
+ * Replace the project database with a backup. The current database is backed up
145
+ * first, so a restore from the wrong file is itself recoverable; that safety
146
+ * copy is also why the backup is not validated before it is put in place.
147
+ */
148
+ export async function restore(root, backupPath, { apply = false } = {}) {
149
+ // The control centre holds this exact file open. Replacing it underneath a
150
+ // live process, and blanking the sidecar the engine is using, is how a restore
151
+ // corrupts the thing it was meant to rescue.
152
+ if (apply) await assertNoLiveService(root);
153
+ const p = paths(root);
154
+ // A bare name is resolved against the backups directory, because that is what
155
+ // every command prints. `db backup` reports the name it wrote and `db status`
156
+ // lists them by name, and passing one of those names back produced "there is
157
+ // no backup file named ..." for a file sitting exactly where it was left. The
158
+ // path was being resolved against the working directory instead.
159
+ const named = basename(backupPath) === backupPath ? join(p.backups, backupPath) : null;
160
+ const from = named && existsSync(named) ? named : resolve(backupPath);
161
+ if (!existsSync(from)) {
162
+ const known = (await listBackups(root)).slice(0, 5).map((b) => b.name);
163
+ throw new DbError(E.NO_BACKUP, known.length
164
+ ? `There is no backup file named ${basename(from)}. The most recent are: ${known.join(", ")}.`
165
+ : `There is no backup file named ${basename(from)}, and no backup has been taken yet.`);
166
+ }
167
+
168
+ const replaces = existsSync(p.db);
169
+ const plan = {
170
+ from,
171
+ to: p.db,
172
+ bytes: statSync(from).size,
173
+ replaces,
174
+ backsUpCurrent: replaces,
175
+ };
176
+ if (!apply) return { applied: false, plan, safetyBackup: null };
177
+
178
+ const safetyBackup = replaces ? await backup(root, "pre-restore") : null;
179
+ mkdirSync(p.dir, { recursive: true });
180
+ copyFileSync(from, p.db);
181
+ resetSidecars(p.db);
182
+ return { applied: true, plan, safetyBackup };
183
+ }
184
+
185
+ /** A VACUUM INTO snapshot carries everything, so any leftover log must go. */
186
+ function resetSidecars(dbFile) {
187
+ for (const suffix of SIDECARS) writeFileSync(dbFile + suffix, "");
188
+ }
189
+
190
+ // --------------------------------------------------------------------- export
191
+
192
+ // SQLite values that JSON cannot carry on its own. Blobs (the memory embedding
193
+ // vectors) survive as base64, big integers as decimal strings.
194
+ const encodeValue = (v) => {
195
+ if (v instanceof Uint8Array) return { $blob: Buffer.from(v).toString("base64") };
196
+ if (typeof v === "bigint") return { $int: v.toString() };
197
+ return v;
198
+ };
199
+
200
+ const decodeValue = (v) => {
201
+ if (v && typeof v === "object") {
202
+ if (typeof v.$blob === "string") return Buffer.from(v.$blob, "base64");
203
+ if (typeof v.$int === "string") return Number(v.$int);
204
+ }
205
+ return v;
206
+ };
207
+
208
+ /**
209
+ * Write a portable JSONL snapshot: one header line, then one `{table, row}` line
210
+ * per row in table creation order and rowid order within a table. Two exports of
211
+ * an unchanged database differ only in the header timestamp.
212
+ */
213
+ export async function exportProject(root, { out } = {}) {
214
+ const p = paths(root);
215
+ if (!existsSync(p.db)) throw missingDatabase();
216
+ const at = new Date().toISOString();
217
+
218
+ const snapshot = await query(root, async (db) => {
219
+ const project = await currentProject(db);
220
+ const schemaVersion = Number(await db.value("PRAGMA user_version"));
221
+ const lines = [];
222
+ const tables = [];
223
+ for (const table of await tableNames(db)) {
224
+ const rows = await db.all(`SELECT * FROM ${table} ORDER BY rowid`);
225
+ for (const row of rows) {
226
+ const clean = {};
227
+ for (const [k, v] of Object.entries(row)) clean[k] = encodeValue(v);
228
+ lines.push(JSON.stringify({ table, row: clean }));
229
+ }
230
+ tables.push({ table, rows: rows.length });
231
+ }
232
+ return { project, schemaVersion, lines, tables };
233
+ });
234
+
235
+ const header = {
236
+ format: EXPORT_FORMAT,
237
+ schemaVersion: snapshot.schemaVersion,
238
+ project: snapshot.project
239
+ ? { id: snapshot.project.id, name: snapshot.project.name, slug: snapshot.project.slug }
240
+ : null,
241
+ at,
242
+ tables: snapshot.tables,
243
+ };
244
+
245
+ const path = resolve(
246
+ out ?? join(p.exports, `${snapshot.project?.slug ?? "project"}-${stamp(at)}.jsonl`),
247
+ );
248
+ mkdirSync(dirname(path), { recursive: true });
249
+ // ponytail: the whole snapshot is built in memory. A project database is
250
+ // thousands of rows, not millions; stream it if that ever stops being true.
251
+ writeFileSync(path, [JSON.stringify(header), ...snapshot.lines].join("\n") + "\n");
252
+
253
+ return {
254
+ path,
255
+ bytes: statSync(path).size,
256
+ at,
257
+ rows: snapshot.lines.length,
258
+ tables: snapshot.tables,
259
+ };
260
+ }
261
+
262
+ // --------------------------------------------------------------------- import
263
+
264
+ /** Parse and validate an export file. Groups rows by table, keeping file order. */
265
+ function readExport(file) {
266
+ const path = resolve(file);
267
+ if (!existsSync(path)) throw new DbError(E.BAD_EXPORT, `There is no export file at ${basename(path)}.`);
268
+ const lines = readFileSync(path, "utf8").split("\n").filter((line) => line.trim());
269
+ if (!lines.length) throw new DbError(E.BAD_EXPORT, "That export file is empty.");
270
+
271
+ let header;
272
+ try {
273
+ header = JSON.parse(lines[0]);
274
+ } catch {
275
+ throw new DbError(E.BAD_EXPORT, "The first line of that file is not a Superdev export header.");
276
+ }
277
+ if (header?.format !== EXPORT_FORMAT) {
278
+ throw new DbError(E.BAD_EXPORT, `That file is not a ${EXPORT_FORMAT} export.`);
279
+ }
280
+ if (!Number.isInteger(header.schemaVersion)) {
281
+ throw new DbError(E.BAD_EXPORT, "The export header has no schema version, so it cannot be checked against this database.");
282
+ }
283
+ if (!header.project?.id) {
284
+ throw new DbError(E.BAD_EXPORT, "The export header names no project, so it cannot be matched against this database.");
285
+ }
286
+
287
+ const byTable = new Map();
288
+ for (let i = 1; i < lines.length; i++) {
289
+ let entry;
290
+ try {
291
+ entry = JSON.parse(lines[i]);
292
+ } catch {
293
+ throw new DbError(E.BAD_EXPORT, `Line ${i + 1} of the export is not valid JSON.`);
294
+ }
295
+ if (!entry?.table || !entry.row || typeof entry.row !== "object") {
296
+ throw new DbError(E.BAD_EXPORT, `Line ${i + 1} of the export is not a {table, row} record.`);
297
+ }
298
+ const row = {};
299
+ for (const [k, v] of Object.entries(entry.row)) row[k] = decodeValue(v);
300
+ if (!byTable.has(entry.table)) byTable.set(entry.table, []);
301
+ byTable.get(entry.table).push(row);
302
+ }
303
+ return { path, header, byTable };
304
+ }
305
+
306
+ /**
307
+ * Load an export into an existing database, in one transaction.
308
+ *
309
+ * Rows are inserted, never overwritten: a row whose primary key is already there
310
+ * is left alone. That keeps the import idempotent, keeps it off the append-only
311
+ * history tables, and means a local edit is never silently replaced by a
312
+ * snapshot. Replacing a database wholesale is what `restore` is for.
313
+ */
314
+ export async function importProject(root, file, { apply = false } = {}) {
315
+ // Bulk inserts across sixty tables while the control centre reads from a
316
+ // pinned snapshot means the interface shows a half arrived project and calls
317
+ // it current. The import is transactional, so nothing is corrupted; what is
318
+ // wrong is answering with a state nobody chose to be in.
319
+ if (apply) await assertNoLiveService(root);
320
+ const p = paths(root);
321
+ if (!existsSync(p.db)) {
322
+ throw new DbError(
323
+ E.NO_DATABASE,
324
+ "This project has no database to import into. Use hydrate to build one from an export.",
325
+ );
326
+ }
327
+ const { header, byTable } = readExport(file);
328
+
329
+ const current = await query(root, async (db) => ({
330
+ schemaVersion: Number(await db.value("PRAGMA user_version")),
331
+ projects: await db.all("SELECT id FROM projects"),
332
+ tables: new Set(await tableNames(db)),
333
+ }));
334
+
335
+ if (header.schemaVersion > current.schemaVersion) {
336
+ throw new DbError(
337
+ E.SCHEMA_AHEAD,
338
+ `The export is at schema version ${header.schemaVersion} and this database is at ${current.schemaVersion}. Migrate the database first.`,
339
+ );
340
+ }
341
+ if (current.projects.length && !current.projects.some((r) => r.id === header.project.id)) {
342
+ const here = current.projects.map((r) => r.id).join(", ");
343
+ throw new DbError(
344
+ E.PROJECT_MISMATCH,
345
+ `This database already holds ${here} and the export holds ${header.project.id}. ` +
346
+ "Import only into an empty database or into the same project.",
347
+ );
348
+ }
349
+
350
+ const known = [...byTable.keys()].filter((t) => current.tables.has(t));
351
+ const skipped = [...byTable.keys()].filter((t) => !current.tables.has(t));
352
+ const plan = {
353
+ project: header.project,
354
+ schemaVersion: header.schemaVersion,
355
+ into: current.projects.length ? "existing project" : "empty database",
356
+ tables: known.map((table) => ({ table, rows: byTable.get(table).length })),
357
+ skipped,
358
+ };
359
+ if (!apply) return { applied: false, plan, tables: [], rows: 0, inserted: 0, skipped };
360
+
361
+ const tables = await mutate(root, async (db) => {
362
+ // The export replays parents before children, but an export written by a
363
+ // different schema version need not, and deferring costs nothing.
364
+ await db.exec("PRAGMA defer_foreign_keys = ON");
365
+ const out = [];
366
+ for (const table of known) {
367
+ const columns = new Set((await db.all(`PRAGMA table_info(${table})`)).map((c) => c.name));
368
+ let inserted = 0;
369
+ let present = 0;
370
+ for (const row of byTable.get(table)) {
371
+ const keys = Object.keys(row).filter((k) => columns.has(k));
372
+ const info = await db.run(
373
+ `INSERT OR IGNORE INTO ${table} (${keys.join(",")}) VALUES (${keys.map(() => "?").join(",")})`,
374
+ ...keys.map((k) => row[k]),
375
+ );
376
+ if (info.changes) inserted++;
377
+ else present++;
378
+ }
379
+ out.push({ table, rows: byTable.get(table).length, inserted, present });
380
+ }
381
+ return out;
382
+ });
383
+
384
+ return {
385
+ applied: true,
386
+ plan,
387
+ project: header.project,
388
+ tables,
389
+ rows: tables.reduce((n, t) => n + t.rows, 0),
390
+ inserted: tables.reduce((n, t) => n + t.inserted, 0),
391
+ skipped,
392
+ };
393
+ }
394
+
395
+ /**
396
+ * Build a project database from an export. Refuses to touch an existing one, and
397
+ * removes the database it created if the import fails, so a retry still sees an
398
+ * empty project rather than a half-built one.
399
+ */
400
+ export async function hydrate(root, file) {
401
+ const p = paths(root);
402
+ if (existsSync(p.db)) {
403
+ throw new DbError(
404
+ E.NOT_EMPTY,
405
+ "This project already has a database. Move .superdev aside first, or restore from a backup instead.",
406
+ );
407
+ }
408
+ readExport(file); // fail before creating anything if the file is not an export
409
+
410
+ const migrations = await ensureDatabase(root, { apply: true });
411
+ try {
412
+ const result = await importProject(root, file, { apply: true });
413
+ return { ...result, created: true, migrations };
414
+ } catch (err) {
415
+ rmSync(p.db, { force: true });
416
+ for (const suffix of SIDECARS) rmSync(p.db + suffix, { force: true });
417
+ throw err;
418
+ }
419
+ }