diff --git a/crates/vfs_canister/vfs.did b/crates/vfs_canister/vfs.did index 75b4b3e..dc4433a 100644 --- a/crates/vfs_canister/vfs.did +++ b/crates/vfs_canister/vfs.did @@ -93,6 +93,7 @@ type DatabaseStatus = variant { Restoring; Archiving; Archived; + Deleted; Pending; }; type DatabaseSummary = record { @@ -104,6 +105,7 @@ type DatabaseSummary = record { cycles_suspended_at_ms : opt int64; database_id : text; archived_at_ms : opt int64; + deleted_at_ms : opt int64; }; type DeleteDatabaseRequest = record { database_id : text }; type DeleteNodeRequest = record { diff --git a/crates/vfs_cli_app/src/commands_fs_tests.rs b/crates/vfs_cli_app/src/commands_fs_tests.rs index 63d4fcd..9366491 100644 --- a/crates/vfs_cli_app/src/commands_fs_tests.rs +++ b/crates/vfs_cli_app/src/commands_fs_tests.rs @@ -81,6 +81,7 @@ impl VfsApi for MockClient { cycles_balance: Some(10), cycles_suspended_at_ms: None, archived_at_ms: None, + deleted_at_ms: None, }]) } diff --git a/crates/vfs_cli_core/src/commands.rs b/crates/vfs_cli_core/src/commands.rs index 0f607b8..d2b9290 100644 --- a/crates/vfs_cli_core/src/commands.rs +++ b/crates/vfs_cli_core/src/commands.rs @@ -1584,6 +1584,7 @@ mod tests { cycles_balance: Some(1_000_000), cycles_suspended_at_ms: None, archived_at_ms: None, + deleted_at_ms: None, }]) } async fn begin_database_archive(&self, database_id: &str) -> Result { diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index b7cb787..e7d10d6 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -5452,7 +5452,7 @@ fn load_database_summaries_for_caller( .prepare( "SELECT d.database_id, d.name, d.status, m.role, d.logical_size_bytes, COALESCE(b.balance_cycles, 0), b.suspended_at_ms, - d.archived_at_ms + d.archived_at_ms, d.deleted_at_ms FROM databases d INNER JOIN database_members m ON m.database_id = d.database_id LEFT JOIN database_cycle_accounts b ON b.database_id = d.database_id @@ -5472,6 +5472,7 @@ fn load_database_summaries_for_caller( cycles_balance: Some(cycles_balance.max(0) as u64), cycles_suspended_at_ms: crate::sqlite::row_get(row, 6)?, archived_at_ms: crate::sqlite::row_get(row, 7)?, + deleted_at_ms: crate::sqlite::row_get(row, 8)?, }) }) .map_err(|error| error.to_string()) @@ -5569,6 +5570,7 @@ fn status_from_db(status: &str) -> crate::sqlite::Result { "archiving" => Ok(DatabaseStatus::Archiving), "archived" => Ok(DatabaseStatus::Archived), "restoring" => Ok(DatabaseStatus::Restoring), + "deleted" => Ok(DatabaseStatus::Deleted), _ => Err(crate::sqlite::invalid_query()), } } @@ -5580,6 +5582,7 @@ fn status_to_db(status: DatabaseStatus) -> &'static str { DatabaseStatus::Archiving => "archiving", DatabaseStatus::Archived => "archived", DatabaseStatus::Restoring => "restoring", + DatabaseStatus::Deleted => "deleted", } } diff --git a/crates/vfs_runtime/tests/database_service_pbt_ext.rs b/crates/vfs_runtime/tests/database_service_pbt_ext.rs index 3aa4d97..23c507e 100644 --- a/crates/vfs_runtime/tests/database_service_pbt_ext.rs +++ b/crates/vfs_runtime/tests/database_service_pbt_ext.rs @@ -392,7 +392,10 @@ proptest! { service .finalize_database_archive(&database_id, OWNER, hash.clone(), 22) .expect("archive should finalize"); - assert_eq!(status_and_mount(service, &database_id).0, DatabaseStatus::Archived); + assert_eq!( + status_and_mount(service, &database_id).0, + DatabaseStatus::Archived + ); } let bad_hash = vec![8_u8; 32]; diff --git a/crates/vfs_types/src/fs.rs b/crates/vfs_types/src/fs.rs index 8e00d44..c0dc0d3 100644 --- a/crates/vfs_types/src/fs.rs +++ b/crates/vfs_types/src/fs.rs @@ -26,16 +26,18 @@ pub struct DatabaseMember { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] #[serde(rename_all = "snake_case")] pub enum DatabaseStatus { - #[serde(alias = "Pending")] - Pending, #[serde(alias = "Active")] Active, + #[serde(alias = "Pending")] + Pending, + #[serde(alias = "Restoring")] + Restoring, #[serde(alias = "Archiving")] Archiving, #[serde(alias = "Archived")] Archived, - #[serde(alias = "Restoring")] - Restoring, + #[serde(alias = "Deleted")] + Deleted, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] @@ -60,6 +62,7 @@ pub struct DatabaseSummary { pub cycles_balance: Option, pub cycles_suspended_at_ms: Option, pub archived_at_ms: Option, + pub deleted_at_ms: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, CandidType)] diff --git a/extensions/wiki-clipper/scripts/check-candid-drift.mjs b/extensions/wiki-clipper/scripts/check-candid-drift.mjs index de52b58..574e7df 100644 --- a/extensions/wiki-clipper/scripts/check-candid-drift.mjs +++ b/extensions/wiki-clipper/scripts/check-candid-drift.mjs @@ -9,7 +9,7 @@ const actor = readFileSync(new URL("../src/vfs-actor.js", import.meta.url), "utf const expectedTypes = { DatabaseRole: { kind: "variant", fields: { Reader: "null", Writer: "null", Owner: "null" } }, - DatabaseStatus: { kind: "variant", fields: { Pending: "null", Active: "null", Restoring: "null", Archiving: "null", Archived: "null" } }, + DatabaseStatus: { kind: "variant", fields: { Active: "null", Pending: "null", Restoring: "null", Archiving: "null", Archived: "null", Deleted: "null" } }, DatabaseSummary: { kind: "record", fields: { @@ -20,7 +20,8 @@ const expectedTypes = { database_id: "text", cycles_balance: "opt nat64", cycles_suspended_at_ms: "opt int64", - archived_at_ms: "opt int64" + archived_at_ms: "opt int64", + deleted_at_ms: "opt int64" } }, CyclesBillingConfig: { diff --git a/extensions/wiki-clipper/src/vfs-actor.js b/extensions/wiki-clipper/src/vfs-actor.js index 664668e..753460a 100644 --- a/extensions/wiki-clipper/src/vfs-actor.js +++ b/extensions/wiki-clipper/src/vfs-actor.js @@ -17,11 +17,12 @@ export async function createVfsActor({ canisterId, host, identity }) { function idlFactory({ IDL: idl }) { const DatabaseRole = idl.Variant({ Reader: idl.Null, Writer: idl.Null, Owner: idl.Null }); const DatabaseStatus = idl.Variant({ - Pending: idl.Null, Active: idl.Null, + Pending: idl.Null, Restoring: idl.Null, Archiving: idl.Null, - Archived: idl.Null + Archived: idl.Null, + Deleted: idl.Null }); const DatabaseSummary = idl.Record({ status: DatabaseStatus, @@ -31,7 +32,8 @@ function idlFactory({ IDL: idl }) { database_id: idl.Text, cycles_balance: idl.Opt(idl.Nat64), cycles_suspended_at_ms: idl.Opt(idl.Int64), - archived_at_ms: idl.Opt(idl.Int64) + archived_at_ms: idl.Opt(idl.Int64), + deleted_at_ms: idl.Opt(idl.Int64) }); const CyclesBillingConfig = idl.Record({ kinic_ledger_canister_id: idl.Text, @@ -164,7 +166,8 @@ function normalizeDatabaseSummary(raw) { status: normalizeDatabaseStatus(raw.status), logicalSizeBytes: raw.logical_size_bytes?.toString?.() ?? String(raw.logical_size_bytes ?? "0"), cyclesBalance: raw.cycles_balance?.[0]?.toString?.() ?? "0", - cyclesSuspendedAtMs: raw.cycles_suspended_at_ms?.[0]?.toString?.() ?? null + cyclesSuspendedAtMs: raw.cycles_suspended_at_ms?.[0]?.toString?.() ?? null, + deletedAtMs: raw.deleted_at_ms?.[0]?.toString?.() ?? null }; } @@ -189,6 +192,7 @@ function databaseCyclesDisabledReason(database, config) { const minimum = parseCycles(config?.minUpdateCycles); if (!config) return "Cycles config unavailable."; if (database.status === "Pending") return "Database activation is pending until its first cycle purchase completes."; + if (database.status !== "Active") return "Database is not writable in its current lifecycle state."; if (database.cyclesSuspendedAtMs) return "Database cycles are suspended."; if (balance < minimum) return "Database cycles balance is below the minimum update balance."; return null; diff --git a/extensions/wiki-clipper/tests/offscreen.test.mjs b/extensions/wiki-clipper/tests/offscreen.test.mjs index c7cb67a..87a1f40 100644 --- a/extensions/wiki-clipper/tests/offscreen.test.mjs +++ b/extensions/wiki-clipper/tests/offscreen.test.mjs @@ -367,6 +367,7 @@ test("listWritableDatabases returns active writable database summaries", async ( logicalSizeBytes: "0", cyclesBalance: "20000", cyclesSuspendedAtMs: null, + deletedAtMs: null, writeCyclesAvailable: true, cyclesReason: null } @@ -406,7 +407,8 @@ function writeCyclesActorMethods({ databaseId = "team-db", balanceCycles = 20_00 logical_size_bytes: 0n, cycles_balance: [balanceCycles], cycles_suspended_at_ms: suspendedAtMs === null ? [] : [suspendedAtMs], - archived_at_ms: [] + archived_at_ms: [], + deleted_at_ms: [] } ] }; @@ -433,6 +435,7 @@ function rawDatabase(databaseId, name, role, status) { logical_size_bytes: 0n, cycles_balance: [20_000n], cycles_suspended_at_ms: [], - archived_at_ms: [] + archived_at_ms: [], + deleted_at_ms: [] }; } diff --git a/extensions/wiki-clipper/tests/settings.test.mjs b/extensions/wiki-clipper/tests/settings.test.mjs index b011d46..7169479 100644 --- a/extensions/wiki-clipper/tests/settings.test.mjs +++ b/extensions/wiki-clipper/tests/settings.test.mjs @@ -264,6 +264,7 @@ function rawDatabase(databaseId, role, status, nameOrBalance = 20_000n, cyclesSu logical_size_bytes: 0n, cycles_balance: [cyclesBalance], cycles_suspended_at_ms: cyclesSuspendedAtMs === null ? [] : [cyclesSuspendedAtMs], - archived_at_ms: [] + archived_at_ms: [], + deleted_at_ms: [] }; } diff --git a/skill-registry-web/lib/types.ts b/skill-registry-web/lib/types.ts index d675d2e..9e8042b 100644 --- a/skill-registry-web/lib/types.ts +++ b/skill-registry-web/lib/types.ts @@ -97,7 +97,7 @@ export type CanisterHealth = { }; export type DatabaseRole = "reader" | "writer" | "owner"; -export type DatabaseStatus = "pending" | "active" | "restoring" | "archiving" | "archived"; +export type DatabaseStatus = "pending" | "active" | "restoring" | "archiving" | "archived" | "deleted"; export type DatabaseSummary = { databaseId: string; @@ -108,6 +108,7 @@ export type DatabaseSummary = { cyclesBalance: string | null; cyclesSuspendedAtMs: string | null; archivedAtMs: string | null; + deletedAtMs: string | null; }; export type DatabaseMember = { diff --git a/skill-registry-web/lib/vfs-client.ts b/skill-registry-web/lib/vfs-client.ts index f205157..464e01b 100644 --- a/skill-registry-web/lib/vfs-client.ts +++ b/skill-registry-web/lib/vfs-client.ts @@ -15,7 +15,7 @@ type Variant = Record; type RawNode = { path: string; kind: Variant; content: string; created_at: bigint; updated_at: bigint; etag: string; metadata_json: string }; type RawNodeMutationAck = { path: string; kind: Variant; updated_at: bigint; etag: string }; type RawChild = { path: string; name: string; kind: Variant; updated_at: [] | [bigint]; etag: [] | [string]; size_bytes: [] | [bigint]; is_virtual: boolean; has_children: boolean }; -type RawDatabaseSummary = { status: Variant; role: Variant; logical_size_bytes: bigint; database_id: string; name: string; archived_at_ms: [] | [bigint]; cycles_balance: [] | [bigint]; cycles_suspended_at_ms: [] | [bigint] }; +type RawDatabaseSummary = { status: Variant; role: Variant; logical_size_bytes: bigint; database_id: string; name: string; archived_at_ms: [] | [bigint]; deleted_at_ms: [] | [bigint]; cycles_balance: [] | [bigint]; cycles_suspended_at_ms: [] | [bigint] }; type RawDatabaseMember = { database_id: string; principal: string; role: Variant; created_at_ms: bigint }; type RawWriteNodeRequest = { database_id: string; path: string; kind: Variant; content: string; metadata_json: string; expected_etag: [] | [string] }; type RawWriteNodeResult = { created: boolean; node: RawNodeMutationAck }; @@ -182,6 +182,7 @@ function normalizeDatabaseSummary(raw: RawDatabaseSummary): DatabaseSummary { cyclesBalance: raw.cycles_balance[0]?.toString() ?? null, cyclesSuspendedAtMs: raw.cycles_suspended_at_ms[0]?.toString() ?? null, archivedAtMs: raw.archived_at_ms[0]?.toString() ?? null, + deletedAtMs: raw.deleted_at_ms[0]?.toString() ?? null, }; } @@ -191,6 +192,7 @@ function normalizeDatabaseStatus(status: Variant): DatabaseStatus { if ("Restoring" in status) return "restoring"; if ("Archiving" in status) return "archiving"; if ("Archived" in status) return "archived"; + if ("Deleted" in status) return "deleted"; throw new ApiError(`Unknown database status variant: ${Object.keys(status).join(",")}`, 502); } diff --git a/skill-registry-web/lib/vfs-idl.ts b/skill-registry-web/lib/vfs-idl.ts index e49f08a..9086efd 100644 --- a/skill-registry-web/lib/vfs-idl.ts +++ b/skill-registry-web/lib/vfs-idl.ts @@ -8,10 +8,11 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { const DatabaseRole = idl.Variant({ Reader: idl.Null, Writer: idl.Null, Owner: idl.Null }); const DatabaseStatus = idl.Variant({ Active: idl.Null, + Pending: idl.Null, Restoring: idl.Null, Archiving: idl.Null, Archived: idl.Null, - Pending: idl.Null + Deleted: idl.Null }); const DatabaseSummary = idl.Record({ status: DatabaseStatus, @@ -20,6 +21,7 @@ export const idlFactory: ActorInterfaceFactory = ({ IDL: idl }) => { database_id: idl.Text, name: idl.Text, archived_at_ms: idl.Opt(idl.Int64), + deleted_at_ms: idl.Opt(idl.Int64), cycles_balance: idl.Opt(idl.Nat64), cycles_suspended_at_ms: idl.Opt(idl.Int64) }); diff --git a/skill-registry-web/scripts/check-skill-registry-web.mjs b/skill-registry-web/scripts/check-skill-registry-web.mjs index f1dbee8..0583302 100644 --- a/skill-registry-web/scripts/check-skill-registry-web.mjs +++ b/skill-registry-web/scripts/check-skill-registry-web.mjs @@ -61,13 +61,17 @@ assert.match(operations, /frontmatterEnd\(rest\)/); assert.doesNotMatch(operations, /indexOf\("\\n---"\)/); assert.match(wikiHelpers, /path === "\/Sources\/raw" \|\| path\.startsWith\("\/Sources\/raw\/"\)/); assert.doesNotMatch(wikiHelpers, /path\.startsWith\("\/Sources\/raw"\)/); -assert.match(types, /DatabaseStatus = "pending" \| "active" \| "restoring" \| "archiving" \| "archived"/); +assert.match(types, /DatabaseStatus = "pending" \| "active" \| "restoring" \| "archiving" \| "archived" \| "deleted"/); assert.doesNotMatch(vfsIdl, /Hot: idl\.Null/); assert.match(vfsIdl, /Pending: idl\.Null/); assert.match(vfsIdl, /Active: idl\.Null/); +assert.match(vfsIdl, /status: DatabaseStatus/); +assert.match(vfsIdl, /Deleted: idl\.Null/); +assert.match(vfsIdl, /deleted_at_ms: idl\.Opt\(idl\.Int64\)/); assert.match(vfsClient, /function normalizeDatabaseStatus/); assert.match(vfsClient, /"Active" in status/); assert.match(vfsClient, /"Pending" in status/); +assert.match(vfsClient, /"Deleted" in status/); assert.doesNotMatch(vfsClient, /: "hot"/); assert.doesNotMatch(client, /from ["']..\/..\/..\/wikibrowser/); assert.doesNotMatch(panels, /from ["']..\/..\/..\/wikibrowser/); diff --git a/wikibrowser/README.md b/wikibrowser/README.md index ee3b4f3..ce8fe0e 100644 --- a/wikibrowser/README.md +++ b/wikibrowser/README.md @@ -143,8 +143,8 @@ pnpm smoke:errors -- --base-url http://127.0.0.1:3000 --database-id { const CanisterHealth = idl.Record({ cycles_balance: idl.Nat }); const DatabaseRole = idl.Variant({ Reader: idl.Null, Writer: idl.Null, Owner: idl.Null }); const DatabaseStatus = idl.Variant({ - Hot: idl.Null, + Active: idl.Null, + Pending: idl.Null, Restoring: idl.Null, Archiving: idl.Null, Archived: idl.Null, diff --git a/wikibrowser/scripts/candid-shapes.mjs b/wikibrowser/scripts/candid-shapes.mjs index edd4e73..5280d10 100644 --- a/wikibrowser/scripts/candid-shapes.mjs +++ b/wikibrowser/scripts/candid-shapes.mjs @@ -1,7 +1,7 @@ export const expectedTypes = { CanisterHealth: { kind: "record", fields: { cycles_balance: "nat" } }, DatabaseRole: { kind: "variant", cases: { Reader: "null", Writer: "null", Owner: "null" } }, - DatabaseStatus: { kind: "variant", cases: { Hot: "null", Restoring: "null", Archiving: "null", Archived: "null", Deleted: "null" } }, + DatabaseStatus: { kind: "variant", cases: { Active: "null", Pending: "null", Restoring: "null", Archiving: "null", Archived: "null", Deleted: "null" } }, DatabaseSummary: { kind: "record", fields: { diff --git a/wikibrowser/scripts/check-candid-drift.mjs b/wikibrowser/scripts/check-candid-drift.mjs index d3c4e5f..dc39e84 100644 --- a/wikibrowser/scripts/check-candid-drift.mjs +++ b/wikibrowser/scripts/check-candid-drift.mjs @@ -1,21 +1,27 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { expectedMethods, expectedTypes } from "./candid-shapes.mjs"; +import { didTypeAliases, expectedMethods, expectedTypes } from "./candid-shapes.mjs"; import { generateVfsIdlFromDid } from "./generate-vfs-idl.mjs"; const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, "..", ".."); +const did = readFileSync(join(root, "crates", "vfs_canister", "vfs.did"), "utf8"); const idl = readFileSync(join(here, "..", "lib", "vfs-idl.ts"), "utf8"); +const didTypes = parseDidTypes(did); +const didMethods = parseDidMethods(did); const idlTypes = parseIdlTypes(idl); const idlMethods = parseIdlMethods(idl); const failures = []; for (const [name, shape] of Object.entries(expectedTypes)) { + compareShape(`vfs.did type ${name}`, didTypes[didTypeAliases[name] ?? name], shape); compareShape(`vfs-idl.ts type ${name}`, idlTypes[name], shape); } for (const [name, shape] of Object.entries(expectedMethods)) { + compareMethod(`vfs.did method ${name}`, didMethods[name], shape); compareMethod(`vfs-idl.ts method ${name}`, idlMethods[name], shape); } @@ -26,9 +32,9 @@ for (const name of Object.keys(idlMethods)) { } try { - const generated = generateVfsIdlFromDid(); + const generated = generateVfsIdlFromDid(did); if (idl !== generated) { - failures.push("wikibrowser/lib/vfs-idl.ts is not generated from scripts/candid-shapes.mjs; run node scripts/generate-vfs-idl.mjs"); + failures.push("wikibrowser/lib/vfs-idl.ts is not generated from crates/vfs_canister/vfs.did; run node scripts/generate-vfs-idl.mjs"); } } catch (error) { failures.push(error instanceof Error ? error.message : String(error)); @@ -41,6 +47,44 @@ if (failures.length > 0) { console.log(`Candid subset shape OK: ${Object.keys(expectedMethods).join(", ")}`); +function parseDidTypes(source) { + const types = {}; + for (const match of source.matchAll(/^type\s+(\w+)\s*=\s*(record|variant)\s*\{([^]*?)\};/gm)) { + const [, name, kind, body] = match; + types[name] = kind === "record" ? { kind, fields: parseDidFields(body) } : { kind, cases: parseDidFields(body) }; + } + return types; +} + +function parseDidFields(body) { + const fields = {}; + for (const raw of body.split(";")) { + const line = raw.trim(); + if (!line) continue; + const match = line.match(/^"?(\w+)"?\s*(?::\s*(.+))?$/); + if (!match) continue; + fields[match[1]] = normalizeShape(match[2] ?? "null"); + } + return fields; +} + +function parseDidMethods(source) { + const service = source.match(/service\s*:\s*\([^)]*\)\s*->\s*\{([^]*?)\n\}/m)?.[1] ?? ""; + const methods = {}; + for (const raw of service.split(";")) { + const line = raw.trim(); + if (!line) continue; + const match = line.match(/^(\w+)\s*:\s*\(([^)]*)\)\s*->\s*\(([^)]*)\)(?:\s+(\w+))?$/); + if (!match) continue; + methods[match[1]] = { + input: splitShapes(match[2]), + output: normalizeResultAlias(match[3]), + mode: match[4] ?? "update" + }; + } + return methods; +} + function parseIdlTypes(source) { const types = {}; for (const declaration of extractIdlConstDeclarations(source)) { @@ -136,6 +180,16 @@ function normalizeIdlShape(value) { return normalizeBlobAlias(normalized); } +function splitShapes(value) { + const trimmed = value.trim(); + if (!trimmed) return []; + return trimmed.split(",").map((part) => normalizeShape(part)); +} + +function normalizeShape(value) { + return normalizeBlobAlias(value.trim().replace(/\s+/g, " ")); +} + function splitIdlInputs(value) { const trimmed = value.trim(); if (!trimmed) return []; @@ -148,6 +202,34 @@ function normalizeBlobAlias(value) { return value; } +function normalizeResultAlias(value) { + const normalized = normalizeShape(value).replace(/,$/, "").trim(); + if (normalized === "Result_10") return "ResultLinks"; + if (normalized === "Result_11") return "ResultLinks"; + if (normalized === "Result_12") return "ResultChildren"; + if (normalized === "Result_13") return "ResultCyclesEntries"; + if (normalized === "Result_14") return "ResultCyclesPendingPurchases"; + if (normalized === "Result_15") return "ResultMembers"; + if (normalized === "Result_16") return "ResultDatabases"; + if (normalized === "Result_1") return "ResultUnit"; + if (normalized === "Result_4") return "ResultCreateDatabase"; + if (normalized === "Result_5") return "ResultDeleteNode"; + if (normalized === "Result_18") return "ResultMkdirNode"; + if (normalized === "Result_19") return "ResultMoveNode"; + if (normalized === "Result_20") return "ResultCyclesPurchase"; + if (normalized === "Result_21") return "ResultQueryContext"; + if (normalized === "Result_24") return "ResultNode"; + if (normalized === "Result_25") return "ResultNodeContext"; + if (normalized === "Result_26") return "ResultSearch"; + if (normalized === "Result_27") return "ResultStorageBillingBatch"; + if (normalized === "Result_28") return "ResultSourceEvidence"; + if (normalized === "Result_3") return "ResultOpsAnswerSessionCheck"; + if (normalized === "Result_30") return "ResultWriteSourceForGeneration"; + if (normalized === "Result_9") return "ResultCyclesBillingConfig"; + if (normalized === "Result") return "ResultWriteNode"; + return normalized; +} + function compareShape(label, actual, expected) { if (!actual) { failures.push(`${label} missing`); @@ -181,7 +263,7 @@ function compareMethod(label, actual, expected) { } function canonicalTypeName(name) { - return name; + return didTypeAliases[name] ?? name; } function compareMap(label, actual, expected) { diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index 0cab614..36b5249 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -195,12 +195,14 @@ assert.match(dashboardHomeClient, /\$\{walletLabel\(provider\)\} purchased \$\{c assert.match(apiErrors, /wiki_api_version_mismatch/); assert.match(apiErrors, /Wiki VFS API response unavailable\./); assert.match(apiErrors, /CandidDecodeError\|Cannot find field hash\|subtype\|type mismatch\|variant, expected fields/); -assert.match(vfsIdl, /Hot: idl\.Null/); assert.match(vfsIdl, /Deleted: idl\.Null/); assert.match(vfsIdl, /deleted_at_ms: idl\.Opt\(idl\.Int64\)/); -assert.doesNotMatch(vfsIdl, /Active: idl\.Null/); -assert.doesNotMatch(vfsIdl, /Pending: idl\.Null/); -assert.match(vfsClient, /if \("Hot" in status\) \{\s*return "active";\s*\}/); +assert.doesNotMatch(vfsIdl, /Hot: idl\.Null/); +assert.match(vfsIdl, /Active: idl\.Null/); +assert.match(vfsIdl, /Pending: idl\.Null/); +assert.match(vfsIdl, /status: DatabaseStatus/); +assert.match(vfsClient, /if \("Active" in status\) \{\s*return "active";\s*\}/); +assert.match(vfsClient, /if \("Pending" in status\) \{\s*return "pending";\s*\}/); assert.match(vfsClient, /if \("Deleted" in status\) \{\s*return "deleted";\s*\}/); assert.match(dashboardHomeClient, /myDatabases = databases\.filter\(\(database\) => database\.member\)/); assert.match(dashboardHomeClient, /publicDatabases = databases\.filter\(\(database\) => !database\.member && database\.publicReadable\)/); diff --git a/wikibrowser/scripts/generate-vfs-idl.mjs b/wikibrowser/scripts/generate-vfs-idl.mjs index 271e52b..c3e9169 100644 --- a/wikibrowser/scripts/generate-vfs-idl.mjs +++ b/wikibrowser/scripts/generate-vfs-idl.mjs @@ -1,9 +1,11 @@ import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { expectedMethods, expectedTypes } from "./candid-shapes.mjs"; +import { didTypeAliases, expectedMethods, expectedTypes } from "./candid-shapes.mjs"; const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, "..", ".."); +const didPath = join(root, "crates", "vfs_canister", "vfs.did"); const idlPath = join(here, "..", "lib", "vfs-idl.ts"); const typeOrder = [ @@ -146,13 +148,14 @@ const methodOrder = [ "write_source_for_generation" ]; -export function generateVfsIdlFromDid() { +export function generateVfsIdlFromDid(didSource) { + validateDidSubset(didSource); validateRenderOrder(); return renderVfsIdl(); } if (process.argv[1] === fileURLToPath(import.meta.url)) { - const generated = generateVfsIdlFromDid(); + const generated = generateVfsIdlFromDid(readFileSync(didPath, "utf8")); if (process.argv.includes("--check")) { const current = readFileSync(idlPath, "utf8"); if (current !== generated) { @@ -168,7 +171,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { function renderVfsIdl() { const lines = [ - "// Generated by scripts/generate-vfs-idl.mjs from scripts/candid-shapes.mjs.", + "// Generated by scripts/generate-vfs-idl.mjs from crates/vfs_canister/vfs.did.", "// Do not edit by hand.", 'import { Actor } from "@icp-sdk/core/agent";', 'import { IDL } from "@icp-sdk/core/candid";', @@ -250,6 +253,24 @@ function shapeToIdl(shape) { return primitives[shape] ?? shape; } +function validateDidSubset(source) { + const didTypes = parseDidTypes(source); + const didMethods = parseDidMethods(source); + const failures = []; + + for (const [name, shape] of Object.entries(expectedTypes)) { + compareShape(failures, `vfs.did type ${name}`, didTypes[didTypeAliases[name] ?? name], shape); + } + + for (const [name, shape] of Object.entries(expectedMethods)) { + compareMethod(failures, `vfs.did method ${name}`, didMethods[name], shape); + } + + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } +} + function validateRenderOrder() { const missingTypes = Object.keys(expectedTypes).filter((name) => !typeOrder.includes(name)); const missingMethods = Object.keys(expectedMethods).filter((name) => !methodOrder.includes(name)); @@ -266,3 +287,133 @@ function validateRenderOrder() { throw new Error(failures.join("\n")); } } + +function parseDidTypes(source) { + const types = {}; + for (const match of source.matchAll(/^type\s+(\w+)\s*=\s*(record|variant)\s*\{([^]*?)\};/gm)) { + const [, name, kind, body] = match; + types[name] = kind === "record" ? { kind, fields: parseDidFields(body) } : { kind, cases: parseDidFields(body) }; + } + return types; +} + +function parseDidFields(body) { + const fields = {}; + for (const raw of body.split(";")) { + const line = raw.trim(); + if (!line) continue; + const match = line.match(/^"?(\w+)"?\s*(?::\s*(.+))?$/); + if (!match) continue; + fields[match[1]] = normalizeShape(match[2] ?? "null"); + } + return fields; +} + +function parseDidMethods(source) { + const service = source.match(/service\s*:\s*\([^)]*\)\s*->\s*\{([^]*?)\n\}/m)?.[1] ?? ""; + const methods = {}; + for (const raw of service.split(";")) { + const line = raw.trim(); + if (!line) continue; + const match = line.match(/^(\w+)\s*:\s*\(([^)]*)\)\s*->\s*\(([^)]*)\)(?:\s+(\w+))?$/); + if (!match) continue; + methods[match[1]] = { + input: splitShapes(match[2]), + output: normalizeResultAlias(match[3]), + mode: match[4] ?? "update" + }; + } + return methods; +} + +function splitShapes(value) { + const trimmed = value.trim(); + if (!trimmed) return []; + return trimmed.split(",").map((part) => normalizeShape(part)); +} + +function normalizeShape(value) { + return normalizeBlobAlias(value.trim().replace(/\s+/g, " ")); +} + +function normalizeBlobAlias(value) { + if (value === "vec nat8") return "blob"; + if (value === "opt vec nat8") return "opt blob"; + return value; +} + +function normalizeResultAlias(value) { + const normalized = normalizeShape(value).replace(/,$/, "").trim(); + if (normalized === "Result_10") return "ResultLinks"; + if (normalized === "Result_11") return "ResultLinks"; + if (normalized === "Result_12") return "ResultChildren"; + if (normalized === "Result_13") return "ResultCyclesEntries"; + if (normalized === "Result_14") return "ResultCyclesPendingPurchases"; + if (normalized === "Result_15") return "ResultMembers"; + if (normalized === "Result_16") return "ResultDatabases"; + if (normalized === "Result_1") return "ResultUnit"; + if (normalized === "Result_4") return "ResultCreateDatabase"; + if (normalized === "Result_5") return "ResultDeleteNode"; + if (normalized === "Result_18") return "ResultMkdirNode"; + if (normalized === "Result_19") return "ResultMoveNode"; + if (normalized === "Result_20") return "ResultCyclesPurchase"; + if (normalized === "Result_21") return "ResultQueryContext"; + if (normalized === "Result_24") return "ResultNode"; + if (normalized === "Result_25") return "ResultNodeContext"; + if (normalized === "Result_26") return "ResultSearch"; + if (normalized === "Result_27") return "ResultStorageBillingBatch"; + if (normalized === "Result_28") return "ResultSourceEvidence"; + if (normalized === "Result_3") return "ResultOpsAnswerSessionCheck"; + if (normalized === "Result_30") return "ResultWriteSourceForGeneration"; + if (normalized === "Result_9") return "ResultCyclesBillingConfig"; + if (normalized === "Result") return "ResultWriteNode"; + return normalized; +} + +function compareShape(failures, label, actual, expected) { + if (!actual) { + failures.push(`${label} missing`); + return; + } + if (actual.kind !== expected.kind) { + failures.push(`${label} kind mismatch: ${actual.kind} != ${expected.kind}`); + return; + } + compareMap(failures, label, actual.fields ?? actual.cases, expected.fields ?? expected.cases); +} + +function compareMethod(failures, label, actual, expected) { + if (!actual) { + failures.push(`${label} missing`); + return; + } + const actualInput = actual.input.map(canonicalTypeName); + const expectedInput = expected.input.map(canonicalTypeName); + if (JSON.stringify(actualInput) !== JSON.stringify(expectedInput)) { + failures.push(`${label} input mismatch: ${actual.input.join(", ")} != ${expected.input.join(", ")}`); + } + if (actual.output !== expected.output) { + failures.push(`${label} output mismatch: ${actual.output} != ${expected.output}`); + } + if (actual.mode !== expected.mode) { + failures.push(`${label} mode mismatch: ${actual.mode} != ${expected.mode}`); + } +} + +function canonicalTypeName(name) { + return didTypeAliases[name] ?? name; +} + +function compareMap(failures, label, actual, expected) { + const actualKeys = Object.keys(actual).sort(); + const expectedKeys = Object.keys(expected).sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + failures.push(`${label} fields mismatch: ${actualKeys.join(", ")} != ${expectedKeys.join(", ")}`); + return; + } + for (const key of expectedKeys) { + if (actual[key] !== expected[key]) { + failures.push(`${label}.${key} mismatch: ${actual[key]} != ${expected[key]}`); + } + } +}