@celilo/cli 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/drizzle/0031_module_config_source.sql +20 -0
  2. package/drizzle/meta/_journal.json +8 -1
  3. package/package.json +3 -3
  4. package/schemas/system_config.json +7 -1
  5. package/src/ansible/inventory.test.ts +2 -1
  6. package/src/api/sessions.test.ts +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/backup-rename.test.ts +2 -1
  9. package/src/cli/cli.test.ts +2 -1
  10. package/src/cli/commands/console-get-chain.test.ts +2 -1
  11. package/src/cli/commands/firewall-interface-list.test.ts +158 -8
  12. package/src/cli/commands/firewall-interface-list.ts +73 -7
  13. package/src/cli/commands/machine-add.ts +12 -55
  14. package/src/cli/commands/module-config.test.ts +22 -2
  15. package/src/cli/commands/module-deploy.ts +8 -2
  16. package/src/cli/commands/module-generate.test.ts +53 -0
  17. package/src/cli/commands/module-generate.ts +31 -26
  18. package/src/cli/commands/module-import-aspect.test.ts +2 -1
  19. package/src/cli/commands/module-import-registry.test.ts +2 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-operations.test.ts +2 -1
  22. package/src/cli/commands/module-publish.test.ts +5 -12
  23. package/src/cli/commands/module-update.test.ts +87 -4
  24. package/src/cli/commands/module-update.ts +14 -4
  25. package/src/cli/commands/module-upgrade.test.ts +15 -0
  26. package/src/cli/commands/module-upgrade.ts +54 -2
  27. package/src/cli/commands/module-verify.test.ts +2 -3
  28. package/src/cli/commands/module-verify.ts +0 -1
  29. package/src/cli/commands/monitor.ts +2 -10
  30. package/src/cli/commands/notify-config.test.ts +5 -3
  31. package/src/cli/commands/registry-owner.test.ts +2 -1
  32. package/src/cli/commands/registry-token.test.ts +2 -1
  33. package/src/cli/commands/restore.ts +16 -6
  34. package/src/cli/commands/system-apply-config-equivalence.test.ts +5 -7
  35. package/src/cli/commands/system-config.test.ts +148 -0
  36. package/src/cli/commands/system-config.ts +26 -1
  37. package/src/cli/commands/system-doctor.test.ts +71 -0
  38. package/src/cli/commands/system-doctor.ts +110 -24
  39. package/src/cli/commands/system-init-deprecation.test.ts +6 -3
  40. package/src/cli/commands/system-migrate.test.ts +2 -1
  41. package/src/cli/generate-zsh-completion.ts +1 -1
  42. package/src/cli/index.ts +6 -4
  43. package/src/cli/restore-command.test.ts +2 -1
  44. package/src/cli/restore-migration-failure.test.ts +160 -0
  45. package/src/config/paths.test.ts +3 -3
  46. package/src/db/client.ts +5 -0
  47. package/src/db/migrate.test.ts +62 -135
  48. package/src/db/migrate.ts +17 -12
  49. package/src/db/schema.ts +10 -0
  50. package/src/hooks/broker.test.ts +106 -2
  51. package/src/hooks/broker.ts +91 -1
  52. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  53. package/src/hooks/capability-loader.test.ts +78 -0
  54. package/src/hooks/capability-loader.ts +52 -3
  55. package/src/hooks/define-hook.test.ts +4 -3
  56. package/src/hooks/executor.test.ts +106 -19
  57. package/src/hooks/executor.ts +120 -13
  58. package/src/hooks/hook-jail-toolchain-reach.test.ts +3 -2
  59. package/src/hooks/hook-jail-unreachability.test.ts +4 -3
  60. package/src/hooks/hook-protocol.ts +46 -1
  61. package/src/hooks/hook-runner.ts +36 -0
  62. package/src/hooks/hook-store-proxy.test.ts +109 -0
  63. package/src/hooks/hook-store-proxy.ts +85 -0
  64. package/src/hooks/hook-store.test.ts +168 -0
  65. package/src/hooks/hook-store.ts +290 -0
  66. package/src/hooks/hook-timeout.test.ts +3 -2
  67. package/src/hooks/hook-trespass.test.ts +39 -5
  68. package/src/hooks/jail.test.ts +62 -3
  69. package/src/hooks/jail.ts +67 -8
  70. package/src/hooks/mount-set.test.ts +208 -0
  71. package/src/hooks/mount-set.ts +62 -14
  72. package/src/hooks/run-named-hook.ts +2 -0
  73. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  74. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  75. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  76. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  77. package/src/hooks/unjailed-lint.test.ts +22 -6
  78. package/src/manifest/schema.ts +1 -0
  79. package/src/module/packaging/audit.ts +9 -26
  80. package/src/module/packaging/build-paths.test.ts +127 -0
  81. package/src/module/packaging/build-paths.ts +175 -0
  82. package/src/module/packaging/build.test.ts +71 -1
  83. package/src/module/packaging/build.ts +130 -2
  84. package/src/module/packaging/extract.ts +1 -5
  85. package/src/module/web-root.ts +17 -1
  86. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  87. package/src/policy/module-script-scan.test.ts +42 -1
  88. package/src/policy/module-script-scan.ts +235 -5
  89. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  90. package/src/policy/no-swallowed-refusal.test.ts +264 -0
  91. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  92. package/src/registry/client.test.ts +149 -0
  93. package/src/registry/client.ts +203 -11
  94. package/src/services/alerting/ack.test.ts +2 -1
  95. package/src/services/alerting/cadence-migration.test.ts +3 -2
  96. package/src/services/alerting/coverage-source.test.ts +2 -1
  97. package/src/services/alerting/deferral.test.ts +2 -1
  98. package/src/services/alerting/delivery-loop.test.ts +2 -1
  99. package/src/services/alerting/deploy-hooks.test.ts +2 -1
  100. package/src/services/alerting/format.test.ts +57 -0
  101. package/src/services/alerting/format.ts +24 -0
  102. package/src/services/alerting/inbound-poller.test.ts +2 -1
  103. package/src/services/alerting/inbound.test.ts +2 -1
  104. package/src/services/alerting/notification-responder.test.ts +2 -1
  105. package/src/services/alerting/run-monitor.test.ts +2 -1
  106. package/src/services/alerting/run-monitor.ts +2 -2
  107. package/src/services/alerting/store.test.ts +2 -1
  108. package/src/services/alerting/sweep-runner.test.ts +2 -1
  109. package/src/services/alerting/tokens.test.ts +2 -1
  110. package/src/services/aspect-approvals.test.ts +2 -1
  111. package/src/services/aspect-reconcile.test.ts +4 -3
  112. package/src/services/aspect-runner.test.ts +2 -1
  113. package/src/services/audit/module-integrity.test.ts +0 -21
  114. package/src/services/audit/module-integrity.ts +0 -14
  115. package/src/services/backup-age-agreement.test.ts +2 -1
  116. package/src/services/backup-create.ts +7 -7
  117. package/src/services/backup-envelope-roundtrip.test.ts +47 -3
  118. package/src/services/backup-in-flight-refusal.test.ts +2 -1
  119. package/src/services/backup-restore.ts +8 -4
  120. package/src/services/bus-ensure-flow.test.ts +2 -1
  121. package/src/services/bus-interview-park.test.ts +2 -1
  122. package/src/services/bus-interview.ts +37 -14
  123. package/src/services/bus-secret-flow.test.ts +2 -1
  124. package/src/services/capability-table-rows.test.ts +2 -1
  125. package/src/services/config-provenance.ts +4 -0
  126. package/src/services/consumer-cleanup.test.ts +3 -2
  127. package/src/services/container-service.test.ts +2 -1
  128. package/src/services/control-plane-bootstrap.test.ts +123 -2
  129. package/src/services/control-plane-bootstrap.ts +51 -4
  130. package/src/services/cross-module-read.test.ts +2 -1
  131. package/src/services/deploy-preflight.ts +8 -2
  132. package/src/services/deploy-validation.test.ts +25 -2
  133. package/src/services/deploy-validation.ts +8 -0
  134. package/src/services/dns-discovery.test.ts +54 -0
  135. package/src/services/dns-discovery.ts +47 -5
  136. package/src/services/dns-internal-records.test.ts +3 -2
  137. package/src/services/dns-provider-backfill.test.ts +2 -1
  138. package/src/services/dns-registrations.test.ts +2 -1
  139. package/src/services/ensure-interview.test.ts +3 -2
  140. package/src/services/fleet-checks.test.ts +3 -2
  141. package/src/services/fleet-key.test.ts +68 -3
  142. package/src/services/fleet-key.ts +54 -0
  143. package/src/services/health-runner.ts +2 -0
  144. package/src/services/infrastructure-selector.test.ts +2 -1
  145. package/src/services/infrastructure-variable-resolver.test.ts +2 -1
  146. package/src/services/machine-pool.test.ts +2 -1
  147. package/src/services/module-config.test.ts +2 -1
  148. package/src/services/module-config.ts +20 -2
  149. package/src/services/module-deploy.dns-repoint.test.ts +188 -0
  150. package/src/services/module-deploy.ts +199 -21
  151. package/src/services/module-operations.test.ts +2 -1
  152. package/src/services/module-subscriptions.test.ts +2 -1
  153. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  154. package/src/services/module-validator/git-hygiene.ts +83 -14
  155. package/src/services/port-forwards.test.ts +2 -1
  156. package/src/services/programmatic-responder.aspect.test.ts +2 -1
  157. package/src/services/proxmox-reconcile.test.ts +2 -1
  158. package/src/services/restore-from-file.test.ts +23 -2
  159. package/src/services/restore-from-file.ts +21 -6
  160. package/src/services/restore-preflight.test.ts +2 -1
  161. package/src/services/secret-schema-loader.test.ts +2 -1
  162. package/src/services/ssh-key-manager.test.ts +2 -1
  163. package/src/services/static-content-converge.test.ts +144 -5
  164. package/src/services/static-content-converge.ts +87 -17
  165. package/src/services/system-config-schema-types.ts +1 -1
  166. package/src/services/system-config-validator.test.ts +36 -0
  167. package/src/services/system-config-validator.ts +11 -0
  168. package/src/services/system-state-stage.test.ts +2 -1
  169. package/src/services/trusted-sources.test.ts +33 -2
  170. package/src/services/trusted-sources.ts +47 -10
  171. package/src/services/zone-detector.test.ts +2 -1
  172. package/src/templates/generator.ts +9 -2
  173. package/src/test-utils/bus-responder.ts +5 -3
  174. package/src/test-utils/db-path.ts +25 -0
  175. package/src/test-utils/integration.ts +7 -0
  176. package/src/test-utils/module-fixtures.ts +5 -6
  177. package/src/variables/context.ts +16 -5
  178. package/src/module/packaging/generated-plane.test.ts +0 -79
  179. package/src/module/packaging/generated-plane.ts +0 -134
package/src/cli/index.ts CHANGED
@@ -631,6 +631,7 @@ Subcommands:
631
631
  --preflight Run pre-flight validation only (no deployment)
632
632
  --verbose Keep all sub-events visible (no collapse-on-success)
633
633
  --stop-after-interview Exit after config+secrets interview (no infra/hooks)
634
+ --keep Retain the generated project after a successful deploy (it is deleted by design)
634
635
 
635
636
  health [module-id] Run health checks (transitions to VERIFIED on success)
636
637
  Options:
@@ -951,9 +952,10 @@ Usage:
951
952
  Shows how celilo classifies every interface on a firewall: the zone it
952
953
  matched, the external edge, or ALIEN when nothing accounts for it.
953
954
 
954
- Read-only. It reads the recorded interface table and the declared zone
955
- subnets and classifies in memory — it never touches the box, so it is safe
956
- to run against a firewall whose converge is currently refusing.
955
+ It reads the interface table LIVE from the box, the same way the next
956
+ converge will, so its answer is the answer the converge will act on. If the
957
+ box cannot be reached, it says so and classifies the interface snapshot
958
+ recorded at machine add instead, labelled as possibly stale.
957
959
 
958
960
  With no hostname, every machine celilo classifies as a router is shown.
959
961
 
@@ -2237,7 +2239,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
2237
2239
  }
2238
2240
  const configArgs = parsed.args.slice(1);
2239
2241
  if (configSubcommand === 'set') {
2240
- return handleSystemConfigSet(configArgs);
2242
+ return await handleSystemConfigSet(configArgs, parsed.flags);
2241
2243
  }
2242
2244
  if (configSubcommand === 'get') {
2243
2245
  return handleSystemConfigGet(configArgs);
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { closeDb, getDb } from '../db/client';
17
17
  import { runMigrations } from '../db/migrate';
18
18
  import { modules } from '../db/schema';
19
+ import { resetTestDbPath } from '../test-utils/db-path';
19
20
  import { runCli } from './index';
20
21
 
21
22
  describe('celilo restore', () => {
@@ -32,7 +33,7 @@ describe('celilo restore', () => {
32
33
 
33
34
  afterEach(() => {
34
35
  closeDb();
35
- process.env.CELILO_DB_PATH = undefined;
36
+ resetTestDbPath();
36
37
  process.env.CELILO_DATA_DIR = undefined;
37
38
  process.env.CELILO_MASTER_KEY_PATH = undefined;
38
39
  process.env.CELILO_SUPPRESS_DEPRECATION = undefined;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Tests for the restore migration contract (celilo#1269):
3
+ *
4
+ * 1. A restore whose post-swap migration step fails reports FAILURE, not a
5
+ * warning over a success result. The failure names the recovery command
6
+ * (`celilo system migrate`) and the check that says whether the schema is
7
+ * complete (`celilo system doctor`).
8
+ * 2. A connection held across the migration step is observable at the step's
9
+ * own boundary: migrateRestoredDb throws naming the lock instead of
10
+ * quietly proceeding over a locked database.
11
+ *
12
+ * The round-trip here (encrypted envelope → on_restore hook → staged system
13
+ * files → swap → migrate) is the first module-artifact round-trip at unit
14
+ * level; the envelope mechanics mirror backup-envelope-roundtrip.test.ts.
15
+ */
16
+
17
+ import { Database } from 'bun:sqlite';
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+ import { create as tarCreate } from 'tar';
23
+ import { getDbPath } from '../config/paths';
24
+ import { closeDb, getDb } from '../db/client';
25
+ import { runMigrations } from '../db/migrate';
26
+ import { modules } from '../db/schema';
27
+ import { getOrCreateMasterKey } from '../secrets/master-key';
28
+ import { encryptFileToFile } from '../services/backup-cipher';
29
+ import { buildManifest } from '../services/backup-manifest';
30
+ import { migrateRestoredDb } from '../services/restore-from-file';
31
+ import { resetTestDbPath } from '../test-utils/db-path';
32
+ import { runCli } from './index';
33
+
34
+ const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures');
35
+ const STAGING_HOOK = join(FIXTURES_DIR, 'on-restore-staging-hook.ts');
36
+ const MODULE_ID = 'restore-migration-test';
37
+
38
+ /** The manifest the module row carries: one on_restore hook, the fixture. */
39
+ const MODULE_MANIFEST = {
40
+ celilo_contract: '1.0',
41
+ id: MODULE_ID,
42
+ name: 'Restore Migration Test',
43
+ version: '0.0.1',
44
+ hooks: {
45
+ on_restore: {
46
+ script: STAGING_HOOK,
47
+ timeout: 60000,
48
+ },
49
+ },
50
+ };
51
+
52
+ /**
53
+ * Build an encrypted module artifact whose envelope carries `dbBytes` as the
54
+ * backed-up celilo.db. The fixture on_restore hook stages it into system/ so
55
+ * the restore swaps it over the live DB before the migration step runs.
56
+ */
57
+ async function buildModuleArtifact(dir: string, dbBytes: string): Promise<string> {
58
+ const envelopeDir = join(dir, 'envelope-build');
59
+ mkdirSync(join(envelopeDir, 'data'), { recursive: true });
60
+ writeFileSync(
61
+ join(envelopeDir, 'manifest.json'),
62
+ JSON.stringify(buildManifest({ kind: 'module', moduleId: MODULE_ID, moduleVersion: '0.0.1' })),
63
+ );
64
+ writeFileSync(join(envelopeDir, 'data', 'celilo.db'), dbBytes);
65
+ const tarPath = join(dir, 'envelope.tar');
66
+ await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']);
67
+ const masterKey = await getOrCreateMasterKey();
68
+ const artifactPath = join(dir, 'module.backup');
69
+ await encryptFileToFile(tarPath, artifactPath, masterKey);
70
+ return artifactPath;
71
+ }
72
+
73
+ describe('restore migration failure reporting (celilo#1269)', () => {
74
+ let dir: string;
75
+
76
+ beforeEach(async () => {
77
+ dir = mkdtempSync(join(tmpdir(), 'celilo-restore-mig-fail-test-'));
78
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
79
+ process.env.CELILO_DATA_DIR = dir;
80
+ process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
81
+ await runMigrations(process.env.CELILO_DB_PATH);
82
+
83
+ // The artifact's module must already be imported (restore-from-file's
84
+ // contract with bootstrap.sh). Its sourcePath points at the workspace
85
+ // fixture dir: the canonical <dataDir>/modules/<id> does not exist here,
86
+ // so restore-from-file falls back to sourcePath for the hook.
87
+ const db = getDb();
88
+ db.insert(modules)
89
+ .values({
90
+ id: MODULE_ID,
91
+ name: 'Restore Migration Test',
92
+ version: '0.0.1',
93
+ sourcePath: FIXTURES_DIR,
94
+ manifestData: MODULE_MANIFEST,
95
+ })
96
+ .run();
97
+ });
98
+
99
+ afterEach(() => {
100
+ closeDb();
101
+ resetTestDbPath();
102
+ process.env.CELILO_DATA_DIR = undefined;
103
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
104
+ try {
105
+ rmSync(dir, { recursive: true, force: true });
106
+ } catch {
107
+ /* ignore */
108
+ }
109
+ });
110
+
111
+ test('a restore whose migration step fails reports failure and names the recovery path', async () => {
112
+ // The artifact's celilo.db is not a database at all. The swap lands it
113
+ // (staging bytes are deliberately tolerated so the file-copy path stays
114
+ // testable), and the migration step then fails on open — the same
115
+ // failure surface a locked or half-restored DB produces.
116
+ const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
117
+ const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
118
+
119
+ expect(result.success).toBe(false);
120
+ const err = result.success ? '' : (result.error ?? '');
121
+ expect(err).toContain('migrat');
122
+ expect(err).toContain('celilo system migrate');
123
+ expect(err).toContain('celilo system doctor');
124
+ });
125
+
126
+ test('the swap happened even when migrations failed (the error says so)', async () => {
127
+ const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database');
128
+ const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']);
129
+
130
+ expect(result.success).toBe(false);
131
+ const err = result.success ? '' : (result.error ?? '');
132
+ // The operator must learn the swap DID land — the failure is about the
133
+ // schema step, not a lost restore.
134
+ expect(err).toContain('restored');
135
+ });
136
+
137
+ // The timeout is 30s, not the 5s default: the holder pins the write lock
138
+ // for the full busy_timeout window before the migration step gives up, and
139
+ // the two windows are the same 5s.
140
+ test('a connection held across the migration step throws naming the lock', async () => {
141
+ // Current schema, ledger emptied so drizzle has work to do, then a write
142
+ // lock held across the step. Deleting one ledger row is not enough: the
143
+ // watermark is created_at-based and every migration shares one timestamp,
144
+ // so the whole ledger has to go. The re-run hits "already exists", the
145
+ // ledger-repair path then tries to write, and that write blocks on the
146
+ // holder until busy_timeout expires.
147
+ const livePath = getDbPath();
148
+ const ledger = new Database(livePath);
149
+ ledger.run('DELETE FROM __drizzle_migrations');
150
+ ledger.close();
151
+
152
+ const holder = new Database(livePath);
153
+ holder.run('BEGIN IMMEDIATE');
154
+ try {
155
+ await expect(migrateRestoredDb()).rejects.toThrow(/locked/i);
156
+ } finally {
157
+ holder.close();
158
+ }
159
+ }, 30000);
160
+ });
@@ -141,7 +141,7 @@ describe('paths configuration', () => {
141
141
  it.skipIf(skipIntegration({ platform: 'darwin' }))(
142
142
  'returns data dir + celilo.db on macOS in production',
143
143
  () => {
144
- process.env.CELILO_DB_PATH = undefined;
144
+ delete process.env.CELILO_DB_PATH;
145
145
  process.env.CELILO_DATA_DIR = undefined;
146
146
  process.env.ENVIRONMENT = undefined;
147
147
 
@@ -156,7 +156,7 @@ describe('paths configuration', () => {
156
156
  );
157
157
 
158
158
  it('returns celilo-data/celilo.db in development mode', () => {
159
- process.env.CELILO_DB_PATH = undefined;
159
+ delete process.env.CELILO_DB_PATH;
160
160
  process.env.CELILO_DATA_DIR = undefined;
161
161
  process.env.ENVIRONMENT = 'dev';
162
162
 
@@ -165,7 +165,7 @@ describe('paths configuration', () => {
165
165
  });
166
166
 
167
167
  it('respects CELILO_DATA_DIR override', () => {
168
- process.env.CELILO_DB_PATH = undefined;
168
+ delete process.env.CELILO_DB_PATH;
169
169
  process.env.CELILO_DATA_DIR = '/custom/data';
170
170
  process.env.ENVIRONMENT = undefined;
171
171
 
package/src/db/client.ts CHANGED
@@ -96,6 +96,10 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
96
96
  runMigrationsOn(db);
97
97
  } catch (error) {
98
98
  console.error('Failed to run migrations:', error);
99
+ // Release the file before rethrowing: the caller never receives this
100
+ // connection, so leaving it open holds the WAL lock on a db it cannot
101
+ // use (celilo#1269).
102
+ sqlite.close();
99
103
  throw error;
100
104
  }
101
105
  }
@@ -120,6 +124,7 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
120
124
  }
121
125
  } catch (error) {
122
126
  console.error('Failed to backfill module_systems:', error);
127
+ sqlite.close();
123
128
  throw error;
124
129
  }
125
130
  }
@@ -1,147 +1,74 @@
1
1
  /**
2
- * The frozen-watermark recurrence gate (celilo#169).
2
+ * Tests for the standalone migration entrypoint's connection lifecycle.
3
3
  *
4
- * The state under test is one no correct deploy can produce, so it is seeded
5
- * by hand rather than reached: a database written by a celilo from the
6
- * imperative hand-list era, where schema changes were applied directly and
7
- * `__drizzle_migrations` never recorded them. Its ledger therefore remembers
8
- * an old migration while the tables and columns of every later one are already
9
- * present.
4
+ * runMigrations opens its own connection via createDbClient — which is NOT
5
+ * the process-wide singleton. Its teardown used to call closeDb(), which
6
+ * closes the SINGLETON: a different connection. Two consequences, both bad:
7
+ * the connection this call created was never closed, and a caller that had
8
+ * the singleton open had it closed out from under it.
10
9
  *
11
- * That is the carve-out CLAUDE.md draws around seeding state: deploying
12
- * anything cannot reproduce this row, because current celilo has recorded
13
- * every migration it applied since ISS-0100 made drizzle authoritative. A
14
- * suite that starts from an empty database can only ever prove the forward
15
- * invariant, and says nothing about the installed base.
16
- *
17
- * What made it a hazard: drizzle's migrator is watermark-only. It re-runs
18
- * every migration newer than the newest ledger row, so the first `ALTER TABLE
19
- * ... ADD` dies on `duplicate column name`, the transaction rolls back, and
20
- * the throw happens inside `createDbClient` — so EVERY celilo command on that
21
- * box fails at database open, not just a migrate. celilo-mgr was remediated by
22
- * hand once. This gate is what stops the next one needing a runbook.
10
+ * One bun:sqlite caveat this suite deliberately does NOT paper over: closing
11
+ * a drizzle-wrapped connection releases the file only once drizzle's prepared
12
+ * statements are collected, so a just-closed connection can still hold locks
13
+ * until the next GC. The restore path (the only in-tree caller) does not need
14
+ * the file again after migrating, so the caveat is harmless there — the thing
15
+ * that must hold is that the CLOSE TARGETS THE RIGHT CONNECTION.
23
16
  */
24
17
 
25
- import { afterEach, describe, expect, test } from 'bun:test';
26
- import { rmSync } from 'node:fs';
18
+ import { Database } from 'bun:sqlite';
19
+ import { describe, expect, it } from 'bun:test';
20
+ import { mkdtempSync, rmSync } from 'node:fs';
27
21
  import { tmpdir } from 'node:os';
28
22
  import { join } from 'node:path';
29
- import { type DbClient, createDbClient } from './client';
30
- import { runMigrationsOn } from './migrate';
31
- import { findSchemaDrift } from './schema-introspection';
32
-
33
- describe('runMigrationsOn — a database from the hand-list era', () => {
34
- const paths: string[] = [];
35
-
36
- const freshDb = (): { db: DbClient; path: string } => {
37
- const path = join(tmpdir(), `celilo-migrate-test-${Bun.nanoseconds()}.db`);
38
- paths.push(path);
39
- return { db: createDbClient({ path }), path };
40
- };
41
-
42
- afterEach(() => {
43
- for (const path of paths.splice(0)) {
44
- for (const suffix of ['', '-wal', '-shm']) {
45
- rmSync(`${path}${suffix}`, { force: true });
46
- }
23
+ import { resetTestDbPath } from '../test-utils/db-path';
24
+ import { closeDb, getDb } from './client';
25
+ import { runMigrations } from './migrate';
26
+ import { systemConfig } from './schema';
27
+
28
+ describe('runMigrations connection lifecycle', () => {
29
+ it("does not close the caller's singleton connection", async () => {
30
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-close-test-'));
31
+ try {
32
+ const livePath = join(dir, 'celilo.db');
33
+ process.env.CELILO_DB_PATH = livePath;
34
+ await runMigrations(livePath);
35
+
36
+ // A caller with the singleton open (e.g. the restore CLI between swap
37
+ // and resync) runs migrations on a path and keeps using ITS OWN
38
+ // connection. The old teardown called closeDb(), which closes the
39
+ // singleton — a different connection — so the caller's next query died
40
+ // with a closed-connection error.
41
+ const db = getDb();
42
+ db.insert(systemConfig).values({ key: 'probe', value: 'before' }).run();
43
+
44
+ const otherPath = join(dir, 'other.db');
45
+ await runMigrations(otherPath);
46
+
47
+ // The singleton must have survived the migration on the other path.
48
+ expect(() => db.select().from(systemConfig).limit(1).all()).not.toThrow();
49
+ closeDb();
50
+ } finally {
51
+ closeDb();
52
+ resetTestDbPath();
53
+ rmSync(dir, { recursive: true, force: true });
47
54
  }
48
55
  });
49
56
 
50
- /**
51
- * Freeze the ledger to its oldest entry, leaving the schema fully applied.
52
- * This is the hand-list-era shape: the objects exist, the ledger has
53
- * forgotten who made them.
54
- */
55
- const freezeWatermark = (db: DbClient): void => {
56
- db.$client.run(
57
- 'DELETE FROM `__drizzle_migrations` WHERE created_at > (SELECT MIN(created_at) FROM `__drizzle_migrations`)',
58
- );
59
- };
60
-
61
- const appliedCount = (db: DbClient): number =>
62
- db.$client.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`').get()
63
- ?.c ?? 0;
64
-
65
- test('converges instead of dying on the first already-applied statement', () => {
66
- const { db } = freshDb();
67
- const migrationCount = appliedCount(db);
68
- freezeWatermark(db);
69
- expect(appliedCount(db)).toBe(1);
70
-
71
- // Red before the ledger repair: drizzle re-runs 0001 and throws
72
- // "duplicate column name: role", leaving the ledger frozen.
73
- expect(() => runMigrationsOn(db)).not.toThrow();
74
-
75
- expect(appliedCount(db)).toBe(migrationCount);
76
- });
77
-
78
- test('leaves the schema whole, so the drift detector goes green', () => {
79
- const { db } = freshDb();
80
- freezeWatermark(db);
81
-
82
- runMigrationsOn(db);
83
-
84
- const drift = findSchemaDrift(db.$client);
85
- expect(drift.missingTables).toEqual([]);
86
- expect(drift.missingColumns).toEqual([]);
87
- });
88
-
89
- test('is idempotent — a second pass applies nothing and still converges', () => {
90
- const { db } = freshDb();
91
- freezeWatermark(db);
92
- runMigrationsOn(db);
93
- const afterBaseline = appliedCount(db);
94
-
95
- runMigrationsOn(db);
96
-
97
- expect(appliedCount(db)).toBe(afterBaseline);
98
- });
99
-
100
- /**
101
- * The repair must not become a blanket "assume it already ran". It fires only
102
- * where the answer is unambiguous — the declared schema is entirely present,
103
- * so every migration plainly did run and the ledger is what is wrong. A
104
- * PARTIALLY applied schema is the genuinely hard case, and the one celilo-mgr
105
- * was actually in: it carried 0011's column and not 0010's table. Stamping
106
- * there would record migrations that never ran and bury the missing schema
107
- * for good, so it keeps failing and a human decides.
108
- */
109
- test('refuses to stamp when the schema is only partly there', () => {
110
- const { db } = freshDb();
111
- freezeWatermark(db);
112
- db.$client.run('DROP TABLE `dns_registrations`');
113
-
114
- expect(() => runMigrationsOn(db)).toThrow();
115
- // The ledger is left exactly as found, so the drift is still diagnosable.
116
- expect(appliedCount(db)).toBe(1);
117
- });
118
-
119
- /**
120
- * The path that actually matters. Every celilo command opens the database
121
- * through createDbClient, which migrates on open, so a frozen watermark
122
- * failed there rather than anywhere an operator could aim a fix at — and
123
- * `celilo system migrate`, the command whose whole job is repairing this,
124
- * reached its own repair through the same open and died first.
125
- */
126
- test('repairs on database open, not just when migrate is called by hand', () => {
127
- const { db, path } = freshDb();
128
- const migrationCount = appliedCount(db);
129
- freezeWatermark(db);
130
- db.$client.close();
131
-
132
- const reopened = createDbClient({ path });
133
-
134
- expect(appliedCount(reopened)).toBe(migrationCount);
135
- expect(findSchemaDrift(reopened.$client).missingTables).toEqual([]);
136
- });
137
-
138
- test('a healthy database is untouched by the fallback', () => {
139
- const { db } = freshDb();
140
- const before = appliedCount(db);
141
-
142
- runMigrationsOn(db);
143
-
144
- expect(appliedCount(db)).toBe(before);
145
- expect(findSchemaDrift(db.$client).missingTables).toEqual([]);
57
+ it('closes its own connection: the db reopens cleanly afterwards', async () => {
58
+ const dir = mkdtempSync(join(tmpdir(), 'celilo-migrate-own-conn-test-'));
59
+ try {
60
+ const dbPath = join(dir, 'celilo.db');
61
+ await runMigrations(dbPath);
62
+
63
+ // A fresh connection must be able to take the database over. The old
64
+ // closeDb() closed nothing here (the singleton was null), so this
65
+ // statement ran against a connection held open by nobody's bookkeeping
66
+ // — released only by GC, at a time of GC's choosing.
67
+ const probe = new Database(dbPath, { readonly: true });
68
+ expect(probe.query<{ journal_mode: string }, []>('PRAGMA journal_mode').get()).toBeDefined();
69
+ probe.close();
70
+ } finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
146
73
  });
147
74
  });
package/src/db/migrate.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import { migrate } from 'drizzle-orm/bun-sqlite/migrator';
3
3
  import { readMigrationFiles } from 'drizzle-orm/migrator';
4
- import { type DbClient, closeDb, createDbClient, findMigrationsFolder } from './client';
4
+ import { type DbClient, createDbClient, findMigrationsFolder } from './client';
5
5
  import { findSchemaDrift } from './schema-introspection';
6
6
 
7
7
  /**
@@ -80,21 +80,26 @@ export function runMigrationsOn(db: DbClient): void {
80
80
  }
81
81
 
82
82
  /**
83
- * Run database migrations (standalone entrypoint — `bun run src/db/migrate.ts`).
84
- * createDbClient already migrates on open; this re-asserts for explicit use.
83
+ * Open a database at `dbPath` with migrations applied, then close it.
84
+ * Standalone entrypoint (`bun run src/db/migrate.ts`); tests also use it to
85
+ * prepare a fresh scratch database.
86
+ *
87
+ * createDbClient migrates synchronously on open (its own runMigrationsOn
88
+ * call), so this function is open + close. It deliberately does NOT call
89
+ * runMigrationsOn again: a second pass is always a ledger no-op when the
90
+ * first succeeded, and keeping it invited a double-migrate everywhere this
91
+ * entrypoint is used (celilo#1315).
85
92
  */
86
93
  export async function runMigrations(dbPath?: string) {
87
94
  console.log('Running database migrations...');
88
95
  const db = createDbClient(dbPath ? { path: dbPath } : undefined);
89
- try {
90
- runMigrationsOn(db);
91
- console.log('Migrations completed successfully');
92
- } catch (error) {
93
- console.error('Migration failed:', error);
94
- throw error;
95
- } finally {
96
- closeDb();
97
- }
96
+ console.log('Migrations completed successfully');
97
+ // Close the connection THIS call created. It is not the singleton —
98
+ // createDbClient does not register one — so an old closeDb() here closed
99
+ // whatever else was open (usually nothing) and leaked this connection,
100
+ // which then held the file in WAL and blocked every later journal-mode
101
+ // switch on it (celilo#1269).
102
+ db.$client.close();
98
103
  }
99
104
 
100
105
  // Run migrations if executed directly
package/src/db/schema.ts CHANGED
@@ -126,6 +126,16 @@ export const moduleConfigs = sqliteTable(
126
126
  key: text('key').notNull(),
127
127
  value: text('value').notNull(),
128
128
  valueJson: text('value_json'), // JSON for complex types (arrays, objects)
129
+ /**
130
+ * Who owns the row (hook-owned-state design D7 — one column shared with
131
+ * `derived-value-recomputation`). 'hook' means the owning module's own
132
+ * hook wrote it via `context.config.set`, and no operator path may touch
133
+ * it. NULL is every row written before the column existed — operator
134
+ * intent, legacy derived seeds, and framework keys alike — and stays the
135
+ * value for operator writes until derived-value-recomputation lands its
136
+ * own classification.
137
+ */
138
+ source: text('source'),
129
139
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
130
140
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
131
141
  },