Skip to content

Commit 6b1b1d2

Browse files
committed
chore: update docs/CLI.md, packages/cli/commands/scan.script.ts, packages/cli/commands/doctor.script.ts +5 more
1 parent 71f8ae8 commit 6b1b1d2

7 files changed

Lines changed: 343 additions & 0 deletions

File tree

docs/CLI.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ The CLI remains the functional reference; GUI labels map to these commands.
1111
| Push | `apisrc push --project-root <path> --workspace <id>` | Collection/environment actions; keys are never written to disk or diagnostics. |
1212
| Live | `apisrc watch --project-root <path>` | Watches source changes; auto-export is opt-in and off by default. |
1313

14+
## Durable state diagnostics
15+
16+
State persistence is shadow-only and opt-in. A normal scan continues to use
17+
the legacy in-memory result as its authority. Use `--shadow` to write the
18+
complete snapshot to the SQLite state database without activating it:
19+
20+
```text
21+
apisrc scan --project-root <path> --shadow
22+
```
23+
24+
Use `doctor --json` (or run `packages/cli/commands/doctor.script.ts` directly
25+
while the command is being exposed by the host) to inspect database version,
26+
migration status, active snapshot, last write, corruption and parity
27+
diagnostics. Secret values are never printed; the report only says that they
28+
were omitted. An absent or unavailable database does not change scan behavior.
29+
1430
`sync` is a carrier over the existing generation handler. It does not scan or
1531
export through a second pipeline. GUI dry-run, retry, cancellation and native
1632
secure storage are host concerns around the same operation contract.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env bun
2+
import { existsSync } from "node:fs";
3+
import { STATE_DB_SCHEMA_VERSION } from "../../contracts/constants/core/state-store.constant.js";
4+
import { hasFlag, readFlag } from "../../core/helpers/argv.helper.js";
5+
import { resolveStateDatabasePath } from "../../core/state/sqlite/state-db-path.service.js";
6+
7+
export interface IDoctorReport {
8+
readonly databasePath: string;
9+
readonly database: "available" | "missing" | "corrupt";
10+
readonly dbVersion: number | null;
11+
readonly migration: "current" | "unavailable";
12+
readonly activeSnapshot: string | null;
13+
readonly lastWrite: string | null;
14+
readonly corruption: string | null;
15+
readonly parity: "not-run" | "unavailable";
16+
readonly secretsOmitted: true;
17+
}
18+
19+
export interface IDoctorOutcome {
20+
readonly code: number;
21+
readonly output: string;
22+
readonly report: IDoctorReport;
23+
}
24+
25+
function missingReport(databasePath: string, reason: string): IDoctorReport {
26+
return {
27+
databasePath,
28+
database: "missing",
29+
dbVersion: null,
30+
migration: "unavailable",
31+
activeSnapshot: null,
32+
lastWrite: null,
33+
corruption: reason,
34+
parity: "unavailable",
35+
secretsOmitted: true,
36+
};
37+
}
38+
39+
export async function runDoctor(argv: string[] = process.argv.slice(2)): Promise<IDoctorOutcome> {
40+
const databasePath = readFlag(argv, "--state-db") ?? resolveStateDatabasePath();
41+
let report: IDoctorReport;
42+
if (!existsSync(databasePath)) {
43+
report = missingReport(databasePath, "State database does not exist");
44+
} else {
45+
try {
46+
const { openStateDatabase } = await import("../../core/state/sqlite/sqlite-connection.adapter.js");
47+
const connection = openStateDatabase(databasePath);
48+
try {
49+
const active = connection.database.query("SELECT active_snapshot_id FROM projects WHERE active_snapshot_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get() as { active_snapshot_id?: string } | null;
50+
const latest = connection.database.query("SELECT captured_at FROM snapshots WHERE status = 'complete' ORDER BY captured_at DESC LIMIT 1").get() as { captured_at?: string } | null;
51+
report = {
52+
databasePath,
53+
database: "available",
54+
dbVersion: connection.version,
55+
migration: connection.version === STATE_DB_SCHEMA_VERSION ? "current" : "unavailable",
56+
activeSnapshot: active?.active_snapshot_id ?? null,
57+
lastWrite: latest?.captured_at ?? null,
58+
corruption: null,
59+
parity: "not-run",
60+
secretsOmitted: true,
61+
};
62+
} finally {
63+
connection.close();
64+
}
65+
} catch (error) {
66+
report = missingReport(databasePath, error instanceof Error ? error.message : String(error));
67+
report = { ...report, database: report.corruption?.includes("Corrupt") ? "corrupt" : "missing" };
68+
}
69+
}
70+
71+
const output = hasFlag(argv, "--json") ? JSON.stringify(report) : [
72+
`Database: ${report.database}`,
73+
`Path: ${report.databasePath}`,
74+
`DB version: ${report.dbVersion ?? "unavailable"}`,
75+
`Migration: ${report.migration}`,
76+
`Active snapshot: ${report.activeSnapshot ?? "none"}`,
77+
`Last write: ${report.lastWrite ?? "none"}`,
78+
`Corruption: ${report.corruption ?? "none"}`,
79+
`Parity: ${report.parity}`,
80+
"Secrets: omitted",
81+
].join("\n");
82+
return { code: report.database === "available" ? 0 : 1, output, report };
83+
}
84+
85+
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
86+
const outcome = await runDoctor(argv);
87+
process.stdout.write(`${outcome.output}\n`);
88+
return outcome.code;
89+
}
90+
91+
if (import.meta.main) process.exit(await main());

packages/cli/commands/scan.script.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,59 @@ import { defaultOrchestrator } from "../../frameworks/framework.registry.js";
2929
import { guessedRootNotice, resolveRoot } from "../../core/helpers/resolve-root.helper.js";
3030
import type { IProjectContext } from "../../contracts/interfaces/core/project-context.interface.js";
3131
import type { IScanOutcome } from "../../contracts/interfaces/cli/scan-outcome.interface.js";
32+
import { hasFlag } from "../../core/helpers/argv.helper.js";
33+
import { StableIdService } from "../../core/state/stable-id.service.js";
34+
import { ShadowStateWriterService, type IShadowWriteDiagnostic } from "../../core/state/shadow-state-writer.service.js";
35+
36+
export interface IScanOptions {
37+
readonly shadow?: boolean;
38+
readonly stateDatabasePath?: string;
39+
}
40+
41+
async function writeShadowSnapshot(root: string, framework: string, routes: ReadonlyArray<{ method: string; uri: string }>, path?: string): Promise<IShadowWriteDiagnostic> {
42+
const [{ SnapshotTransactionService }, { SqliteProjectRepository }, { SqliteSnapshotRepository }, { openStateDatabase }] = await Promise.all([
43+
import("../../core/state/snapshot-transaction.service.js"),
44+
import("../../core/state/sqlite/sqlite-project.repository.js"),
45+
import("../../core/state/sqlite/sqlite-snapshot.repository.js"),
46+
import("../../core/state/sqlite/sqlite-connection.adapter.js"),
47+
]);
48+
const ids = new StableIdService();
49+
const capturedAt = new Date().toISOString();
50+
const projectId = ids.project(root);
51+
const snapshot = {
52+
projectId,
53+
snapshotId: ids.snapshot(`${root}\0${capturedAt}`),
54+
status: "building" as const,
55+
revision: Date.now(),
56+
capturedAt,
57+
diagnostics: [],
58+
metadata: { framework, routeCount: routes.length },
59+
services: [{
60+
serviceId: ids.service(`${root}\0${framework}`),
61+
name: framework,
62+
operations: routes.map((route) => ({
63+
operationId: ids.operation(`${framework}\0${route.method}\0${route.uri}`),
64+
method: route.method,
65+
path: route.uri,
66+
})),
67+
}],
68+
};
69+
const connection = openStateDatabase(path);
70+
try {
71+
const snapshots = new SqliteSnapshotRepository(connection.database as never);
72+
const projects = new SqliteProjectRepository(connection.database as never);
73+
const transactions = new SnapshotTransactionService(connection.database as never, snapshots);
74+
return new ShadowStateWriterService(transactions, projects).write(snapshot, root, true);
75+
} finally {
76+
connection.close();
77+
}
78+
}
3279

3380
/** Scans the project and returns what was found, printing it along the way. */
3481
export async function runScan(
3582
argv: string[] = process.argv.slice(2),
3683
context?: IProjectContext,
84+
options: IScanOptions = {},
3785
): Promise<IScanOutcome> {
3886
const root = context?.projectRoot ?? resolveRoot({ argv }).root;
3987

@@ -85,6 +133,16 @@ export async function runScan(
85133
console.log(` ${r.method.padEnd(6)} ${r.uri}${tags}${desc}`);
86134
}
87135

136+
if (options.shadow ?? hasFlag(argv, "--shadow")) {
137+
try {
138+
const shadow = await writeShadowSnapshot(root, match.framework, routes, options.stateDatabasePath);
139+
if (!shadow.ok) console.error(`⚠ Shadow state write failed: ${shadow.error}`);
140+
else console.log("✔ Shadow state snapshot persisted (legacy remains authoritative)");
141+
} catch (error) {
142+
console.error(`⚠ Shadow state unavailable: ${error instanceof Error ? error.message : String(error)}`);
143+
}
144+
}
145+
88146
return {
89147
code: 0,
90148
root,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { IProjectSnapshot } from "../../contracts/interfaces/core/project-state.interface.js";
2+
import type { ProjectId } from "../../contracts/interfaces/core/stable-ids.interface.js";
3+
import { SnapshotTransactionService } from "./snapshot-transaction.service.js";
4+
5+
export interface IShadowWriteDiagnostic {
6+
readonly ok: boolean;
7+
readonly enabled: boolean;
8+
readonly persisted: boolean;
9+
readonly activated: false;
10+
readonly snapshotId: string;
11+
readonly error?: string;
12+
}
13+
14+
export interface IShadowProjectRepository {
15+
ensure(projectId: ProjectId, rootPath: string, now: string): void;
16+
}
17+
18+
export class ShadowStateWriterService {
19+
public constructor(
20+
private readonly transactions: SnapshotTransactionService,
21+
private readonly projects: IShadowProjectRepository,
22+
private readonly now: () => string = () => new Date().toISOString(),
23+
) {}
24+
25+
public write(
26+
snapshot: IProjectSnapshot,
27+
rootPath: string,
28+
enabled: boolean,
29+
): IShadowWriteDiagnostic {
30+
const base = {
31+
enabled,
32+
activated: false as const,
33+
snapshotId: snapshot.snapshotId.value,
34+
};
35+
if (!enabled) return { ...base, ok: true, persisted: false };
36+
37+
try {
38+
this.projects.ensure(snapshot.projectId, rootPath, this.now());
39+
const complete = this.transactions.write(snapshot);
40+
return { ...base, ok: true, persisted: complete.status === "complete" };
41+
} catch (error) {
42+
return {
43+
...base,
44+
ok: false,
45+
persisted: false,
46+
error: error instanceof Error ? error.message : String(error),
47+
};
48+
}
49+
}
50+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { IProjectSnapshot } from "../../contracts/interfaces/core/project-state.interface.js";
2+
import type { SnapshotId } from "../../contracts/interfaces/core/stable-ids.interface.js";
3+
import { canonicalSnapshotDigest } from "./canonical-snapshot.serializer.js";
4+
5+
export type StateParityStatus = "match" | "mismatch" | "unavailable";
6+
7+
export interface IStateParityDiagnostic {
8+
readonly status: StateParityStatus;
9+
readonly projectId: string;
10+
readonly snapshotId: string;
11+
readonly memoryDigest: string;
12+
readonly sqliteDigest: string | null;
13+
readonly message: string;
14+
}
15+
16+
export interface IParitySnapshotRepository {
17+
get(snapshotId: SnapshotId): IProjectSnapshot | null;
18+
}
19+
20+
export class StateParityService {
21+
public constructor(private readonly snapshots: IParitySnapshotRepository) {}
22+
23+
public compare(memory: IProjectSnapshot): IStateParityDiagnostic {
24+
const memoryDigest = canonicalSnapshotDigest(memory);
25+
let stored: IProjectSnapshot | null;
26+
try {
27+
stored = this.snapshots.get(memory.snapshotId);
28+
} catch (error) {
29+
return {
30+
status: "unavailable",
31+
projectId: memory.projectId.value,
32+
snapshotId: memory.snapshotId.value,
33+
memoryDigest,
34+
sqliteDigest: null,
35+
message: error instanceof Error ? error.message : String(error),
36+
};
37+
}
38+
if (stored === null) {
39+
return {
40+
status: "unavailable",
41+
projectId: memory.projectId.value,
42+
snapshotId: memory.snapshotId.value,
43+
memoryDigest,
44+
sqliteDigest: null,
45+
message: "Snapshot is not available in SQLite",
46+
};
47+
}
48+
const sqliteDigest = canonicalSnapshotDigest(stored);
49+
return {
50+
status: memoryDigest === sqliteDigest ? "match" : "mismatch",
51+
projectId: memory.projectId.value,
52+
snapshotId: memory.snapshotId.value,
53+
memoryDigest,
54+
sqliteDigest,
55+
message: memoryDigest === sqliteDigest ? "Memory and SQLite snapshots match" : "Memory and SQLite snapshots differ",
56+
};
57+
}
58+
59+
public compareActive(memory: IProjectSnapshot, activeSnapshotId: SnapshotId | null): IStateParityDiagnostic {
60+
if (activeSnapshotId === null || activeSnapshotId.value !== memory.snapshotId.value) {
61+
return {
62+
status: "mismatch",
63+
projectId: memory.projectId.value,
64+
snapshotId: memory.snapshotId.value,
65+
memoryDigest: canonicalSnapshotDigest(memory),
66+
sqliteDigest: null,
67+
message: "SQLite active snapshot differs from the in-memory snapshot",
68+
};
69+
}
70+
return this.compare(memory);
71+
}
72+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { IProjectSnapshot } from "../../../packages/contracts/interfaces/core/project-state.interface.js";
3+
import { ShadowStateWriterService } from "../../../packages/core/state/shadow-state-writer.service.js";
4+
5+
const snapshot = (): IProjectSnapshot => ({
6+
projectId: { kind: "project", value: "project-1" },
7+
snapshotId: { kind: "snapshot", value: "snapshot-1" },
8+
status: "building",
9+
revision: 1,
10+
capturedAt: "2026-09-09T00:00:00.000Z",
11+
services: [],
12+
diagnostics: [],
13+
});
14+
15+
describe("shadow state writer", () => {
16+
it("does nothing when shadow persistence is disabled", () => {
17+
const result = new ShadowStateWriterService(null as never, null as never).write(snapshot(), "/tmp/project", false);
18+
expect(result).toEqual({ enabled: false, persisted: false, activated: false, ok: true, snapshotId: "snapshot-1" });
19+
});
20+
21+
it("persists complete state without activation", () => {
22+
const writes: string[] = [];
23+
const result = new ShadowStateWriterService(
24+
{ write: (value: IProjectSnapshot) => ({ ...value, status: "complete" as const }) } as never,
25+
{ ensure: () => writes.push("ensure") },
26+
).write(snapshot(), "/tmp/project", true);
27+
expect(result.ok).toBe(true);
28+
expect(result.persisted).toBe(true);
29+
expect(result.activated).toBe(false);
30+
expect(writes).toEqual(["ensure"]);
31+
});
32+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { IProjectSnapshot } from "../../../packages/contracts/interfaces/core/project-state.interface.js";
3+
import { StateParityService } from "../../../packages/core/state/state-parity.service.js";
4+
5+
const snapshot = (path = "/users"): IProjectSnapshot => ({
6+
projectId: { kind: "project", value: "project-1" },
7+
snapshotId: { kind: "snapshot", value: "snapshot-1" },
8+
status: "complete",
9+
revision: 1,
10+
capturedAt: "2026-09-09T00:00:00.000Z",
11+
services: [{ serviceId: { kind: "service", value: "service-1" }, name: "api", operations: [{ operationId: { kind: "operation", value: "operation-1" }, method: "GET", path }] }],
12+
diagnostics: [],
13+
});
14+
15+
describe("state parity", () => {
16+
it("reports equal canonical memory and SQLite digests", () => {
17+
expect(new StateParityService({ get: () => snapshot() }).compare(snapshot()).status).toBe("match");
18+
});
19+
20+
it("reports a structured mismatch and unavailable database", () => {
21+
expect(new StateParityService({ get: () => snapshot("/other") }).compare(snapshot()).status).toBe("mismatch");
22+
expect(new StateParityService({ get: () => null }).compare(snapshot()).status).toBe("unavailable");
23+
});
24+
});

0 commit comments

Comments
 (0)