@zq-silk/yui 0.14.2 → 0.15.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.
@@ -1,23 +1,24 @@
1
- /** `yui upgrade` validates the exact storage contract supported by this release. */
2
- import { latestStorageVersionState } from "../storage/upgrade/recordVersions.js";
1
+ /** `yui upgrade` plans or applies the supported linear storage migration chain. */
3
2
  import { runStorageUpgrade } from "../storage/upgrade/upgradeOrchestrator.js";
4
- import { usageError } from "../errors/cliError.js";
3
+ import { ensureFileTaskController, stopFileTaskController } from "../controller/clientRuntime.js";
4
+ import { runtimeError, usageError } from "../errors/cliError.js";
5
+ import { acquireHandoverLock, isForeignHandoverLockHeld, isHandoverLockHeld } from "../release/runtimeRelease.js";
5
6
  /**
6
7
  * Run the upgrade command. Parses the public `[--dry-run]` form plus the staged
7
8
  * updater's internal `--update-preflight` form, then returns rendered text,
8
9
  * structured data, and an exit code (0 for a safe result, 5 for a blocker).
9
10
  */
10
- export async function runUpgradeCommand(args, home) {
11
+ export async function runUpgradeCommand(args, home, environment = process.env) {
11
12
  const mode = parseUpgradeArgs(args);
12
- const result = await runStorageUpgrade({
13
- home,
14
- latest: latestStorageVersionState(),
15
- mode
16
- });
13
+ const result = mode === "execute"
14
+ ? await runInteractiveUpgrade(home, environment)
15
+ : mode === "update-apply"
16
+ ? await runUpdateOwnedUpgrade(home, environment)
17
+ : await runStorageUpgrade({ home, mode });
17
18
  return {
18
19
  output: renderUpgradeResult(result, mode),
19
20
  data: result,
20
- exitCode: result.outcome === "blocked" ? 5 : 0
21
+ exitCode: result.outcome === "blocked" || result.outcome === "failed" ? 5 : 0
21
22
  };
22
23
  }
23
24
  function parseUpgradeArgs(args) {
@@ -26,37 +27,121 @@ function parseUpgradeArgs(args) {
26
27
  if (args.length === 1 && args[0] === "--dry-run")
27
28
  return "dry-run";
28
29
  // Intentionally omitted from public command help: this is the machine contract
29
- // used by a staged `yui update`, not a replacement for user-facing dry-turn.
30
+ // used by a staged `yui update`, not a replacement for user-facing dry-run.
30
31
  if (args.length === 1 && args[0] === "--update-preflight")
31
32
  return "update-preflight";
33
+ if (args.length === 1 && args[0] === "--update-apply")
34
+ return "update-apply";
32
35
  throw usageError("Upgrade usage: yui upgrade [--dry-run]");
33
36
  }
37
+ async function runInteractiveUpgrade(home, environment) {
38
+ const preflight = await runStorageUpgrade({ home, mode: "update-preflight" });
39
+ if (preflight.outcome !== "update-preflight"
40
+ || preflight.status === "already-current") {
41
+ return runStorageUpgrade({ home, mode: "execute" });
42
+ }
43
+ const handover = acquireHandoverLock(home);
44
+ let controllerWasRunning = false;
45
+ try {
46
+ const stopped = await stopFileTaskController(home, {
47
+ environment,
48
+ handoverOwnerPid: process.pid
49
+ });
50
+ controllerWasRunning = stopped.stopped;
51
+ const result = await runStorageUpgrade({ home, mode: "execute" });
52
+ if (controllerWasRunning
53
+ && (result.outcome === "upgraded" || result.outcome === "already-current")) {
54
+ try {
55
+ await ensureFileTaskController(home, {
56
+ environment,
57
+ handoverOwnerPid: process.pid
58
+ });
59
+ }
60
+ catch (error) {
61
+ throw runtimeError(`Storage reached version ${result.report.targetVersion}, but the current `
62
+ + `Controller could not restart: ${messageOf(error)} Backup: `
63
+ + `${result.report.backupPath ?? "none"}. Keep the Home quiesced, inspect `
64
+ + "Controller ownership, and start only the current Yui Controller.");
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ finally {
70
+ handover.release();
71
+ }
72
+ }
73
+ async function runUpdateOwnedUpgrade(home, environment) {
74
+ const ownerText = environment.YUI_UPDATE_HANDOVER_OWNER_PID;
75
+ const ownerPid = ownerText === undefined ? Number.NaN : Number(ownerText);
76
+ if (!Number.isSafeInteger(ownerPid)
77
+ || ownerPid < 1
78
+ || process.ppid !== ownerPid
79
+ || !isHandoverLockHeld(home)
80
+ || isForeignHandoverLockHeld(home, ownerPid)) {
81
+ throw runtimeError("The internal update migration requires its direct parent to own the live "
82
+ + "Controller handover lock.");
83
+ }
84
+ return runStorageUpgrade({ home, mode: "execute" });
85
+ }
34
86
  /** Render an {@link UpgradeResult} as concise, CLI-style text. */
35
87
  export function renderUpgradeResult(result, mode) {
36
88
  const header = versionHeader(result);
37
89
  switch (result.outcome) {
38
90
  case "already-current":
39
91
  return `${header}\nStorage is already at the current version; nothing to upgrade.`;
92
+ case "upgrade-plan":
93
+ return [
94
+ header,
95
+ `Upgrade plan: ${renderSteps(result.report.steps)}. Storage was not modified.`
96
+ ].join("\n");
97
+ case "upgraded":
98
+ return [
99
+ header,
100
+ `Storage upgraded through ${renderSteps(result.report.steps)}.`,
101
+ `Backup: ${result.report.backupPath ?? "none"}`
102
+ ].join("\n");
40
103
  case "update-preflight":
41
104
  return `${header}\nUpdate preflight: ${result.status} (${result.stepCount} steps). Storage was not modified.`;
42
105
  case "blocked": {
43
106
  return [
44
107
  header,
45
- `${mode === "dry-run" ? "Dry run" : mode === "update-preflight" ? "Update preflight" : "Upgrade"} blocked at ${result.stage}: ${result.message}`,
108
+ `${mode === "dry-run"
109
+ ? "Dry run"
110
+ : mode === "update-preflight"
111
+ ? "Update preflight"
112
+ : "Upgrade"} blocked at ${result.stage}: ${result.message}`,
46
113
  `Action: ${result.action}`,
47
114
  "The authoritative Home is unchanged."
48
115
  ].join("\n");
49
116
  }
117
+ case "failed":
118
+ return [
119
+ header,
120
+ `Upgrade failed at ${result.stage}: ${result.message}`,
121
+ `Action: ${result.action}`,
122
+ `Backup: ${result.backupPath ?? "none"}`,
123
+ result.sceneUnchanged
124
+ ? "The authoritative Home was restored."
125
+ : "The authoritative Home may have changed; keep it quiesced."
126
+ ].join("\n");
50
127
  }
51
128
  }
52
129
  function versionHeader(result) {
53
130
  const classification = result.classification;
54
- const layout = classification.layoutVersion ?? classification.latestLayoutVersion;
55
- const aggregate = classification.aggregateVersion ?? classification.latestAggregateVersion;
131
+ const storage = classification.storageVersion === undefined
132
+ ? "unknown"
133
+ : String(classification.storageVersion);
56
134
  const verdict = classification.classification.verdict;
57
- const incompatible = classification.incompatibleComponent === undefined
58
- ? ""
59
- : ` incompatibleComponent=${classification.incompatibleComponent}`;
60
- return `Storage: ${verdict} layout=${layout}/${classification.latestLayoutVersion} `
61
- + `aggregate=${aggregate}/${classification.latestAggregateVersion}${incompatible}`;
135
+ return `Storage: ${verdict} version=${storage}/${classification.currentStorageVersion} `
136
+ + `minimum=${classification.minimumSupportedStorageVersion}`;
137
+ }
138
+ function renderSteps(steps) {
139
+ if (steps.length === 0)
140
+ return "no migrations";
141
+ return steps
142
+ .map(({ fromVersion, toVersion, name }) => `${fromVersion}->${toVersion} ${name}`)
143
+ .join(", ");
144
+ }
145
+ function messageOf(error) {
146
+ return error instanceof Error ? error.message : String(error);
62
147
  }
@@ -201,7 +201,7 @@ export function renderRuntimeIdentitySection(input) {
201
201
  ` Entry digest ${build.entryDigest}`,
202
202
  ` Source commit ${build.sourceCommit}`,
203
203
  ` Node ${build.nodeVersion} (${build.platform})`,
204
- ` Storage layout ${storage.logicalLayout} (manifest ${storage.manifestStatus}) · backend ${storage.configuredBackend} · worker ${storage.workerEnabled ? "on" : "off"}`,
204
+ ` Storage version ${storage.storageVersion} (status ${storage.storageStatus}, minimum ${storage.minimumStorageVersion}) · backend ${storage.configuredBackend} · worker ${storage.workerEnabled ? "on" : "off"}`,
205
205
  ` Store files state.json ${storage.physicalStateJson.present ? "present" : "absent"} · yui.db ${storage.physicalDatabase.present ? "present" : "absent"}${storage.physicalDatabase.wal ? " +WAL" : ""}${storage.physicalDatabase.present && storage.physicalDatabase.health !== UNSUPPORTED ? ` (${storage.physicalDatabase.health})` : ""}`
206
206
  ];
207
207
  for (const finding of storage.findings) {
@@ -400,8 +400,8 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
400
400
  homeFilesystemId,
401
401
  controllerInstanceId,
402
402
  version: YUI_VERSION,
403
- storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
404
- aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
403
+ storageVersion: yuiVersionIdentity().storageVersion,
404
+ minimumStorageVersion: yuiVersionIdentity().minimumStorageVersion,
405
405
  ...(status === undefined ? {} : { runtime: status(telemetry) })
406
406
  }
407
407
  });
@@ -675,7 +675,7 @@ export function buildRuntimeIdentityReceipt(input) {
675
675
  }
676
676
  })();
677
677
  return Object.freeze({
678
- schemaVersion: 1,
678
+ schemaVersion: 2,
679
679
  version: YUI_VERSION,
680
680
  executablePath: process.execPath,
681
681
  args: process.argv.slice(1),
@@ -685,8 +685,8 @@ export function buildRuntimeIdentityReceipt(input) {
685
685
  cliRealpath: controllerCliRealpath(),
686
686
  controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
687
687
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
688
- storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
689
- aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
688
+ storageVersion: yuiVersionIdentity().storageVersion,
689
+ minimumStorageVersion: yuiVersionIdentity().minimumStorageVersion,
690
690
  storageBackend: input.storageBackend,
691
691
  workerEnabled: input.workerEnabled,
692
692
  pid: process.pid,
@@ -34,7 +34,7 @@ export const STORAGE_DOCTOR_CHECK_NAMES = Object.freeze([
34
34
  * healthy only when schema, compatibility, and state are all `ok`. Any
35
35
  * `unsupported` (version mismatch / needs-new-version), `invalid` (corrupted /
36
36
  * unreadable), or `missing` (uninitialized) storage check is blocking — even
37
- * though `yui doctor` itself exits 0.
37
+ * and the machine-readable command exits non-zero.
38
38
  */
39
39
  export function summarizeStorageHealth(checks) {
40
40
  const names = new Set(STORAGE_DOCTOR_CHECK_NAMES);
@@ -56,14 +56,16 @@ export function buildDoctorReport(env, executor) {
56
56
  }
57
57
  /**
58
58
  * Read the physical-backend facts for a Home (Issue 01 observability). Every
59
- * field is best-effort read-only evidence: an unreadable database or manifest
60
- * yields `null` for that field, and the `storage state` check reports the
61
- * structural problem separately.
59
+ * field is best-effort read-only evidence: an unreadable database yields
60
+ * `null` for that field, and the `storage state` check reports the structural
61
+ * problem separately.
62
62
  */
63
63
  function inspectStorageDetails(home, env) {
64
64
  const schema = inspectStorageSchema(home);
65
- const logicalLayout = schema.status === "current" || schema.status === "unsupported"
66
- ? schema.currentLayoutVersion
65
+ const storageVersion = schema.status === "current"
66
+ || schema.status === "upgradeable"
67
+ || schema.status === "unsupported"
68
+ ? schema.currentVersion
67
69
  : null;
68
70
  const authoritativeBackend = "sqlite";
69
71
  const dbPath = join(home, CURRENT_DATABASE_FILENAME);
@@ -88,7 +90,8 @@ function inspectStorageDetails(home, env) {
88
90
  }
89
91
  }
90
92
  return {
91
- logicalLayout,
93
+ storageVersion,
94
+ minimumSupportedVersion: schema.minimumSupportedVersion,
92
95
  authoritativeBackend,
93
96
  databasePath,
94
97
  journalMode,
@@ -216,13 +219,22 @@ function checkSchema(state) {
216
219
  return {
217
220
  name: "storage schema",
218
221
  status: "ok",
219
- detail: `current=${state.currentVersion} latest=${state.latestVersion}`
222
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
223
+ + `minimum=${state.minimumSupportedVersion}`
224
+ };
225
+ case "upgradeable":
226
+ return {
227
+ name: "storage schema",
228
+ status: "unsupported",
229
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
230
+ + `minimum=${state.minimumSupportedVersion} migration=available`
220
231
  };
221
232
  case "unsupported":
222
233
  return {
223
234
  name: "storage schema",
224
235
  status: "unsupported",
225
- detail: `current=${state.currentVersion} latest=${state.latestVersion} direction=${state.direction}`
236
+ detail: `current=${state.currentVersion} latest=${state.latestVersion} `
237
+ + `minimum=${state.minimumSupportedVersion} direction=${state.direction}`
226
238
  };
227
239
  case "invalid":
228
240
  return { name: "storage schema", status: "invalid", detail: state.detail };
@@ -251,9 +263,20 @@ function inspectCompatibility(home, homeCheck, schema) {
251
263
  check: {
252
264
  name,
253
265
  status: "unsupported",
254
- detail: `unsupported contract: ${schema.incompatibleComponent} is ${schema.direction} `
255
- + `(current=${schema.currentVersion}, required=${schema.latestVersion}). `
256
- + "Preserve this Home and initialize a new Home."
266
+ detail: `storage version is ${schema.direction} `
267
+ + `(current=${schema.currentVersion}, supported=`
268
+ + `${schema.minimumSupportedVersion}..${schema.latestVersion}).`
269
+ }
270
+ };
271
+ }
272
+ if (schema.status === "upgradeable") {
273
+ return {
274
+ storageStatus: "upgradeable",
275
+ check: {
276
+ name,
277
+ status: "unsupported",
278
+ detail: `migration available from ${schema.currentVersion} to ${schema.latestVersion}; `
279
+ + "run `yui upgrade` or `yui update`."
257
280
  }
258
281
  };
259
282
  }
@@ -262,8 +285,8 @@ function inspectCompatibility(home, homeCheck, schema) {
262
285
  check: {
263
286
  name,
264
287
  status: "ok",
265
- detail: `current layout=${schema.currentLayoutVersion}/${schema.latestLayoutVersion} `
266
- + `aggregate=${schema.currentAggregateSchemaVersion}/${schema.latestAggregateSchemaVersion}`
288
+ detail: `current=${schema.currentVersion}/${schema.latestVersion} `
289
+ + `minimum=${schema.minimumSupportedVersion}`
267
290
  }
268
291
  };
269
292
  }
@@ -13,7 +13,8 @@ import { spawnSync } from "node:child_process";
13
13
  import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import Database from "better-sqlite3";
16
- import { CURRENT_AGGREGATE_SCHEMA_VERSION, CURRENT_STORAGE_LAYOUT_VERSION } from "../storage/storageVersions.js";
16
+ import { CURRENT_STORAGE_VERSION, MIN_SUPPORTED_STORAGE_VERSION } from "../storage/storageVersions.js";
17
+ import { inspectStorageSchema } from "../storage/storageSchema.js";
17
18
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
18
19
  export const UNSUPPORTED = "unsupported";
19
20
  /**
@@ -140,14 +141,7 @@ export function collectRuntimeBuildIdentity(ports) {
140
141
  export function createProductionStorageIdentityPorts(env = process.env) {
141
142
  return {
142
143
  env,
143
- readText: (path) => {
144
- try {
145
- return readFileSync(path, "utf8");
146
- }
147
- catch {
148
- return null;
149
- }
150
- },
144
+ inspectStorage: (home) => inspectStorageSchema(home),
151
145
  fileSize: (path) => {
152
146
  try {
153
147
  return statSync(path).size;
@@ -181,36 +175,12 @@ export function createProductionStorageIdentityPorts(env = process.env) {
181
175
  * are never treated as an alternate authority or a repair source.
182
176
  */
183
177
  export function collectStorageIdentity(home, ports = createProductionStorageIdentityPorts()) {
184
- const manifestPath = join(home, "schema.json");
185
- const manifestText = ports.readText(manifestPath);
186
- let manifestStatus = "uninitialized";
187
- let logicalLayout = UNSUPPORTED;
188
- let aggregateSchemaVersion = UNSUPPORTED;
189
- if (manifestText !== null) {
190
- manifestStatus = "current";
191
- try {
192
- const manifest = JSON.parse(manifestText);
193
- if (typeof manifest.storageVersion === "number"
194
- && Number.isFinite(manifest.storageVersion)) {
195
- logicalLayout = manifest.storageVersion;
196
- }
197
- if (typeof manifest.aggregateSchemaVersion === "number"
198
- && Number.isFinite(manifest.aggregateSchemaVersion)) {
199
- aggregateSchemaVersion = manifest.aggregateSchemaVersion;
200
- }
201
- if (typeof logicalLayout !== "number"
202
- || typeof aggregateSchemaVersion !== "number"
203
- || logicalLayout !== CURRENT_STORAGE_LAYOUT_VERSION
204
- || aggregateSchemaVersion !== CURRENT_AGGREGATE_SCHEMA_VERSION) {
205
- manifestStatus = "unsupported";
206
- }
207
- }
208
- catch {
209
- manifestStatus = "invalid";
210
- logicalLayout = UNSUPPORTED;
211
- aggregateSchemaVersion = UNSUPPORTED;
212
- }
213
- }
178
+ const storage = ports.inspectStorage(home);
179
+ const storageVersion = storage.status === "current"
180
+ || storage.status === "upgradeable"
181
+ || storage.status === "unsupported"
182
+ ? storage.currentVersion
183
+ : UNSUPPORTED;
214
184
  const statePath = join(home, "state.json");
215
185
  const statePresent = ports.exists(statePath);
216
186
  const stateBytes = ports.fileSize(statePath);
@@ -223,7 +193,7 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
223
193
  const workerEnabled = resolveStoreWorkerEnabledForHome(home, ports.env);
224
194
  const dbHealth = dbPresent ? ports.probeDatabaseHealth(dbPath) : null;
225
195
  const findings = [];
226
- if (manifestStatus === "current" && !dbPresent) {
196
+ if (storage.status === "current" && !dbPresent) {
227
197
  findings.push({
228
198
  code: "current-database-missing",
229
199
  severity: "contradiction",
@@ -231,20 +201,48 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
231
201
  remediation: "Preserve this Home for diagnosis and initialize a new Home."
232
202
  });
233
203
  }
234
- if (manifestStatus === "unsupported") {
204
+ if (storage.status === "uninitialized") {
205
+ findings.push({
206
+ code: "storage-uninitialized",
207
+ severity: "needs-repair",
208
+ message: "Yui storage has not been initialized for this Home.",
209
+ remediation: "Run `yui setup` for a new, empty Home."
210
+ });
211
+ }
212
+ if (storage.status === "upgradeable") {
213
+ findings.push({
214
+ code: "storage-upgrade-required",
215
+ severity: "needs-repair",
216
+ message: `Storage version ${storage.currentVersion} requires migration to `
217
+ + `${CURRENT_STORAGE_VERSION}.`,
218
+ remediation: "Run `yui upgrade` or `yui update` before resuming writes."
219
+ });
220
+ }
221
+ if (storage.status === "unsupported") {
235
222
  findings.push({
236
223
  code: "unsupported-storage-contract",
237
224
  severity: "contradiction",
238
- message: "The Home does not match this release's exact storage contract.",
239
- remediation: "Open it read-only with its original Yui version, then let the Operator recreate unfinished work in a new Home."
225
+ message: `Storage version ${storage.currentVersion} is outside the supported migration range `
226
+ + `${MIN_SUPPORTED_STORAGE_VERSION}..${CURRENT_STORAGE_VERSION}.`,
227
+ remediation: storage.direction === "newer"
228
+ ? "Use a newer Yui release."
229
+ : "Use a Yui release whose migration floor includes this Home."
240
230
  });
241
231
  }
242
- if (manifestStatus === "invalid") {
232
+ if (storage.status === "invalid") {
243
233
  findings.push({
244
- code: "invalid-storage-manifest",
234
+ code: "invalid-storage",
245
235
  severity: "contradiction",
246
- message: "schema.json is invalid.",
247
- remediation: "Preserve this Home for diagnosis and initialize a new Home."
236
+ message: storage.detail,
237
+ remediation: "Preserve this Home for diagnosis and restore a known-good backup."
238
+ });
239
+ }
240
+ if (ports.exists(join(home, "schema.json"))) {
241
+ findings.push({
242
+ code: "ignored-legacy-storage-manifest",
243
+ severity: "warning",
244
+ message: "schema.json is legacy metadata and is not a storage authority.",
245
+ remediation: "A successful storage upgrade removes it automatically."
248
246
  });
249
247
  }
250
248
  if (dbPresent && dbHealth !== null && dbHealth !== "ok") {
@@ -274,9 +272,9 @@ export function collectStorageIdentity(home, ports = createProductionStorageIden
274
272
  }
275
273
  return {
276
274
  home,
277
- manifestStatus,
278
- logicalLayout,
279
- aggregateSchemaVersion,
275
+ storageStatus: storage.status,
276
+ storageVersion,
277
+ minimumStorageVersion: MIN_SUPPORTED_STORAGE_VERSION,
280
278
  configuredBackend,
281
279
  workerEnabled,
282
280
  physicalStateJson: {
@@ -484,7 +484,7 @@ function validatePointer(value) {
484
484
  function validateRuntimeIdentity(value) {
485
485
  if (value === null
486
486
  || typeof value !== "object"
487
- || value.schemaVersion !== 1
487
+ || value.schemaVersion !== 2
488
488
  || typeof value.version !== "string"
489
489
  || typeof value.executablePath !== "string"
490
490
  || value.executablePath === ""
@@ -492,6 +492,11 @@ function validateRuntimeIdentity(value) {
492
492
  || typeof value.buildId !== "string"
493
493
  || typeof value.cliRealpath !== "string"
494
494
  || typeof value.controllerRealpath !== "string"
495
+ || !isPositiveInteger(value.controllerProtocolVersion)
496
+ || !isPositiveInteger(value.storageVersion)
497
+ || !isPositiveInteger(value.minimumStorageVersion)
498
+ || value.minimumStorageVersion
499
+ > value.storageVersion
495
500
  || typeof value.pid !== "number"
496
501
  || typeof value.processStartIdentity !== "string"
497
502
  || (value.mode !== "primary"
@@ -520,6 +525,9 @@ function isHandoverPhase(value) {
520
525
  function isStringArray(value) {
521
526
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
522
527
  }
528
+ function isPositiveInteger(value) {
529
+ return Number.isSafeInteger(value) && value > 0;
530
+ }
523
531
  function isEexist(error) {
524
532
  return isNodeError(error) && error.code === "EEXIST";
525
533
  }
@@ -73,7 +73,7 @@ export function extractExactControlArgument(args) {
73
73
  }
74
74
  /**
75
75
  * One read-only gate shared by every managed Task control command. It verifies
76
- * the frozen executable/CLI/Home/digest, protocol and scalar-storage identity,
76
+ * the frozen executable/CLI/Home/digest, protocol and storage identity,
77
77
  * on-disk schema, and any live Controller before command routing may construct
78
78
  * a writable store. Package version alone may advance at the same managed path
79
79
  * so an existing Session can cross an explicitly compatible in-place update.
@@ -92,15 +92,10 @@ export async function assertExactControlPlanePreflight(input, options = {}) {
92
92
  if (storage.status !== "current") {
93
93
  throw new Error(`Exact control-plane storage is not current: ${storage.status}.`);
94
94
  }
95
- if (storage.currentLayoutVersion !== descriptor.identity.storageLayoutVersion) {
96
- throw new Error("Exact control-plane storage layout does not match its frozen descriptor "
97
- + `(expected ${descriptor.identity.storageLayoutVersion}, found `
98
- + `${storage.currentLayoutVersion ?? "unknown"}).`);
99
- }
100
- if (storage.currentAggregateSchemaVersion !== descriptor.identity.aggregateSchemaVersion) {
101
- throw new Error("Exact control-plane aggregate schema does not match its frozen descriptor "
102
- + `(expected ${descriptor.identity.aggregateSchemaVersion}, found `
103
- + `${storage.currentAggregateSchemaVersion ?? "unknown"}).`);
95
+ if (storage.currentVersion !== descriptor.identity.storageVersion) {
96
+ throw new Error("Exact control-plane storage version does not match its frozen descriptor "
97
+ + `(expected ${descriptor.identity.storageVersion}, found `
98
+ + `${storage.currentVersion ?? "unknown"}).`);
104
99
  }
105
100
  // A frozen descriptor authenticates the command that created it; it no
106
101
  // longer pins the Home's deployment pointer for the lifetime of a Session.
@@ -133,15 +128,10 @@ export async function assertCompatibleControlPlanePreflight(input, options = {})
133
128
  if (storage.status !== "current") {
134
129
  throw new Error(`Managed control-plane storage is not current: ${storage.status}.`);
135
130
  }
136
- if (storage.currentLayoutVersion !== identity.storageLayoutVersion) {
137
- throw new Error("Managed control-plane storage layout is incompatible "
138
- + `(expected ${identity.storageLayoutVersion}, found `
139
- + `${storage.currentLayoutVersion ?? "unknown"}).`);
140
- }
141
- if (storage.currentAggregateSchemaVersion !== identity.aggregateSchemaVersion) {
142
- throw new Error("Managed control-plane aggregate schema is incompatible "
143
- + `(expected ${identity.aggregateSchemaVersion}, found `
144
- + `${storage.currentAggregateSchemaVersion ?? "unknown"}).`);
131
+ if (storage.currentVersion !== identity.storageVersion) {
132
+ throw new Error("Managed control-plane storage version is incompatible "
133
+ + `(expected ${identity.storageVersion}, found `
134
+ + `${storage.currentVersion ?? "unknown"}).`);
145
135
  }
146
136
  if (options.checkController !== false) {
147
137
  const call = options.callController ?? defaultCallController;
@@ -162,29 +152,31 @@ export function assertControllerStatusIdentity(status, expected = yuiVersionIden
162
152
  }
163
153
  assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
164
154
  assertControllerField(status.version, expected.version, "version");
165
- assertControllerField(status.storageLayoutVersion, expected.storageLayoutVersion, "storage layout");
166
- assertControllerField(status.aggregateSchemaVersion, expected.aggregateSchemaVersion, "aggregate schema");
155
+ assertControllerField(status.storageVersion, expected.storageVersion, "storage version");
156
+ assertControllerField(status.minimumStorageVersion, expected.minimumStorageVersion, "minimum storage migration version");
167
157
  }
168
158
  function validateVersionIdentity(value) {
169
159
  if (!isRecord(value))
170
160
  throw new Error("Yui version identity is invalid.");
171
161
  const version = requireText(value.version, "Yui version");
172
162
  const controllerProtocolVersion = requireVersion(value.controllerProtocolVersion, "Controller protocol version");
173
- const storageLayoutVersion = requireVersion(value.storageLayoutVersion, "Storage layout version");
174
- const aggregateSchemaVersion = requireVersion(value.aggregateSchemaVersion, "Aggregate schema version");
163
+ const storageVersion = requireVersion(value.storageVersion, "Storage version");
164
+ const minimumStorageVersion = requireVersion(value.minimumStorageVersion, "Minimum storage migration version");
165
+ if (minimumStorageVersion > storageVersion) {
166
+ throw new Error("Minimum storage migration version cannot exceed the current storage version.");
167
+ }
175
168
  return {
176
169
  version,
177
170
  controllerProtocolVersion,
178
- storageLayoutVersion,
179
- aggregateSchemaVersion
171
+ storageVersion,
172
+ minimumStorageVersion
180
173
  };
181
174
  }
182
175
  /** Managed continuity is a protocol/storage contract, not a package pin. */
183
176
  function assertContinuityIdentity(label, expected, actual) {
184
177
  for (const field of [
185
178
  "controllerProtocolVersion",
186
- "storageLayoutVersion",
187
- "aggregateSchemaVersion"
179
+ "storageVersion"
188
180
  ]) {
189
181
  if (expected[field] !== actual[field]) {
190
182
  throw new Error(`${label} ${field} does not match the frozen control plane `
@@ -200,8 +192,7 @@ function assertControllerContinuityIdentity(status, expected) {
200
192
  throw new Error("Controller version is invalid at the managed continuity gate.");
201
193
  }
202
194
  assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
203
- assertControllerField(status.storageLayoutVersion, expected.storageLayoutVersion, "storage layout");
204
- assertControllerField(status.aggregateSchemaVersion, expected.aggregateSchemaVersion, "aggregate schema");
195
+ assertControllerField(status.storageVersion, expected.storageVersion, "storage version");
205
196
  }
206
197
  function assertControllerField(actual, expected, label) {
207
198
  if (actual !== expected) {
@@ -2,12 +2,11 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { StorageRecordError } from "./taskStore.js";
4
4
  import { readSqliteHomeIdentity, SqliteTaskStore } from "./sqliteStore.js";
5
- import { ensureStorageSchema, inspectStorageSchema } from "./storageSchema.js";
6
- export const CURRENT_DATABASE_FILENAME = "yui.db";
5
+ import { CURRENT_DATABASE_FILENAME, inspectStorageSchema } from "./storageSchema.js";
6
+ export { CURRENT_DATABASE_FILENAME } from "./storageSchema.js";
7
7
  /** Initialize a new Home, or open an existing Home at the exact current contract. */
8
8
  export function initializeCurrentTaskStore(home) {
9
9
  if (inspectStorageSchema(home).status === "uninitialized") {
10
- ensureStorageSchema(home);
11
10
  return new SqliteTaskStore(home);
12
11
  }
13
12
  return openCurrentTaskStore(home);
@@ -16,7 +15,10 @@ export function initializeCurrentTaskStore(home) {
16
15
  export function openCurrentTaskStore(home) {
17
16
  const schema = inspectStorageSchema(home);
18
17
  if (schema.status !== "current") {
19
- throw new StorageRecordError("This Home does not use the current storage contract. Preserve it for read-only history and initialize a new Home.");
18
+ throw new StorageRecordError(schema.status === "upgradeable"
19
+ ? `This Home uses storage version ${schema.currentVersion}; run \`yui upgrade\` `
20
+ + `to reach ${schema.latestVersion}.`
21
+ : "This Home does not use a supported storage contract.");
20
22
  }
21
23
  if (!existsSync(join(home, CURRENT_DATABASE_FILENAME))) {
22
24
  throw new StorageRecordError("The current Home is incomplete: yui.db is missing. Preserve it for diagnosis and initialize a new Home.");