diff --git a/crates/vfs_canister/src/lib.rs b/crates/vfs_canister/src/lib.rs index d2ff10b..9342ef4 100644 --- a/crates/vfs_canister/src/lib.rs +++ b/crates/vfs_canister/src/lib.rs @@ -15,6 +15,8 @@ use std::time::Duration; use candid::utils::decode_args; use candid::{CandidType, Decode, Deserialize, Nat, Principal, export_service}; #[cfg(not(test))] +use ic_cdk::api::PerformanceCounterType; +#[cfg(not(test))] use ic_cdk::call::Call; use ic_cdk::{init, post_upgrade, query, update}; #[cfg(target_arch = "wasm32")] @@ -66,6 +68,7 @@ const II_ALTERNATIVE_ORIGINS_BODY: &str = r#"{"alternativeOrigins":["https://wik const ICP_CLI_LOGIN_DISCOVERY_PATH: &str = "/.well-known/ic-cli-login"; const ICP_CLI_LOGIN_PATH: &str = "/login"; const ICP_CLI_LOGIN_HTML: &str = include_str!("icp_cli_login.html"); +const UPDATE_EXECUTION_BASE_CYCLES: u128 = 5_000_000; #[cfg(target_arch = "wasm32")] const INDEX_DB_MEMORY_ID: u16 = 10; @@ -1195,7 +1198,7 @@ thread_local! { static TEST_LAST_LEDGER_FROM: RefCell> = const { RefCell::new(None) }; static TEST_CALLER_PRINCIPAL: RefCell> = const { RefCell::new(None) }; static TEST_DATABASE_CYCLES_PURCHASE_APPLY_FAIL_ONCE: RefCell = const { RefCell::new(false) }; - static TEST_CYCLE_BALANCES: RefCell> = const { RefCell::new(Vec::new()) }; + static TEST_UPDATE_CHARGE_UNITS: RefCell> = const { RefCell::new(Vec::new()) }; } #[cfg(test)] @@ -1216,9 +1219,9 @@ fn set_next_ledger_transfer_from_outcome_for_test(outcome: LedgerTransferFromOut } #[cfg(test)] -fn set_cycle_balances_for_test(balances: Vec) { - TEST_CYCLE_BALANCES.with(|slot| { - slot.replace(balances); +fn set_update_charge_units_for_test(units: Vec) { + TEST_UPDATE_CHARGE_UNITS.with(|slot| { + slot.replace(units); }); } @@ -1230,7 +1233,7 @@ fn clear_ledger_transactions_for_test() { TEST_LEDGER_TRANSFER_FEES.with(|slot| { slot.borrow_mut().clear(); }); - TEST_CYCLE_BALANCES.with(|slot| { + TEST_UPDATE_CHARGE_UNITS.with(|slot| { slot.borrow_mut().clear(); }); } @@ -1397,24 +1400,26 @@ fn now_millis() -> i64 { } } -fn cycle_balance() -> u128 { +fn update_charge_units() -> u128 { #[cfg(test)] { - TEST_CYCLE_BALANCES.with(|slot| { - let mut balances = slot.borrow_mut(); - if balances.is_empty() { - 1_000_000_000_000 - } else { - balances.remove(0) - } + TEST_UPDATE_CHARGE_UNITS.with(|slot| { + let mut units = slot.borrow_mut(); + if units.is_empty() { 0 } else { units.remove(0) } }) } #[cfg(not(test))] { - ic_cdk::api::canister_cycle_balance() + u128::from(ic_cdk::api::performance_counter( + PerformanceCounterType::InstructionCounter, + )) } } +fn update_charge_cycles(before: u128, after: u128) -> u128 { + UPDATE_EXECUTION_BASE_CYCLES + after.saturating_sub(before) +} + fn now_nanos() -> u64 { #[cfg(test)] { @@ -1592,7 +1597,7 @@ where { let caller = caller_text(); let now = now_millis(); - let before_cycles = cycle_balance(); + let before_charge_units = update_charge_units(); SERVICE.with(|slot| { let borrowed = slot.borrow(); let service = borrowed @@ -1600,8 +1605,8 @@ where .ok_or_else(|| "wiki service is not initialized".to_string())?; let cycles_billing_config = authorize(service, &caller)?; let result = f(service, &caller, now); - let after_cycles = cycle_balance(); - let cycles_delta = before_cycles.saturating_sub(after_cycles); + let after_charge_units = update_charge_units(); + let cycles_delta = update_charge_cycles(before_charge_units, after_charge_units); if result.is_ok() && let Some(database_id) = database_id.as_deref() && let Err(error) = service.charge_database_update( diff --git a/crates/vfs_canister/src/tests.rs b/crates/vfs_canister/src/tests.rs index e50fff3..14a22f8 100644 --- a/crates/vfs_canister/src/tests.rs +++ b/crates/vfs_canister/src/tests.rs @@ -36,10 +36,10 @@ use super::{ multi_edit_node, outgoing_links, parse_upgrade_cycles_billing_config_arg, purchase_database_cycles, query_context, query_index_sql_json, read_database_archive_chunk, read_node, read_node_context, rename_database, revoke_database_access, search_node_paths, - search_nodes, set_cycle_balances_for_test, set_next_ledger_transfer_from_outcome_for_test, - set_test_caller_principal_for_test, settle_database_storage_charges, source_evidence, status, - transfer_from_error_outcome, update_cycles_billing_config, write_database_restore_chunk, - write_node, write_nodes, + search_nodes, set_next_ledger_transfer_from_outcome_for_test, + set_test_caller_principal_for_test, set_update_charge_units_for_test, + settle_database_storage_charges, source_evidence, status, transfer_from_error_outcome, + update_cycles_billing_config, write_database_restore_chunk, write_node, write_nodes, }; fn install_test_service() { @@ -1334,8 +1334,9 @@ fn canister_list_databases_hides_deleted_databases() { } #[test] -fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { +fn write_nodes_records_instruction_charge_and_writes_nodes() { install_test_service(); + set_update_charge_units_for_test(vec![10_000, 10_321]); let results = write_nodes(WriteNodesRequest { database_id: "default".to_string(), @@ -1359,7 +1360,16 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { .expect("batch write should succeed"); assert_eq!(results.len(), 2); - assert!(database_charge_methods("default").is_empty()); + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + let charge = entries + .iter() + .find(|entry| entry.kind == "charge") + .expect("charge entry should exist"); + assert_eq!(charge.amount_cycles, -5_000_321); + assert_eq!(charge.cycles_delta, Some(5_000_321)); + assert_eq!(charge.method.as_deref(), Some("write_nodes")); assert!( read_node("default".to_string(), "/Wiki/batch-a.md".to_string()) .expect("read should succeed") @@ -1368,8 +1378,9 @@ fn write_nodes_skips_zero_charge_ledger_and_writes_nodes() { } #[test] -fn write_node_and_write_nodes_skip_zero_charge_ledger() { +fn write_node_and_write_nodes_record_instruction_charges() { install_test_service(); + set_update_charge_units_for_test(vec![7, 11, 13, 19]); write_node(WriteNodeRequest { database_id: "default".to_string(), @@ -1392,7 +1403,18 @@ fn write_node_and_write_nodes_skip_zero_charge_ledger() { }) .expect("batch write should succeed"); - assert!(database_charge_methods("default").is_empty()); + let entries = list_database_cycle_entries("default".to_string(), None, 20) + .expect("database cycles ledger should load") + .entries; + let charges = entries + .iter() + .filter(|entry| entry.kind == "charge") + .collect::>(); + assert_eq!(charges.len(), 2); + assert_eq!(charges[0].method.as_deref(), Some("write_node")); + assert_eq!(charges[0].cycles_delta, Some(5_000_004)); + assert_eq!(charges[1].method.as_deref(), Some("write_nodes")); + assert_eq!(charges[1].cycles_delta, Some(5_000_006)); } #[test] @@ -1405,7 +1427,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() { .and_then(|database| database.cycles_balance) .expect("default database should have cycles balance"); - set_cycle_balances_for_test(vec![1_000_000_000_000, 0]); + set_update_charge_units_for_test(vec![0, 1_000_000_000_000]); let written = write_node(WriteNodeRequest { database_id: "default".to_string(), path: "/Wiki/overdrawn.md".to_string(), @@ -1439,7 +1461,7 @@ fn write_node_overdrawn_charge_consumes_balance_and_suspends_database() { .expect("charge entry should exist"); assert_eq!(charge.amount_cycles, -(before_balance as i64)); assert_eq!(charge.balance_after_cycles, 0); - assert_eq!(charge.cycles_delta, Some(1_000_000_000_000)); + assert_eq!(charge.cycles_delta, Some(1_000_005_000_000)); assert_eq!(charge.method.as_deref(), Some("write_node")); } diff --git a/crates/vfs_runtime/src/lib.rs b/crates/vfs_runtime/src/lib.rs index 6ef7544..0ef24d6 100644 --- a/crates/vfs_runtime/src/lib.rs +++ b/crates/vfs_runtime/src/lib.rs @@ -5,7 +5,7 @@ mod sqlite; use std::collections::BTreeMap; #[cfg(not(target_arch = "wasm32"))] -use std::fs::{File, OpenOptions, create_dir_all, metadata, remove_file}; +use std::fs::{File, OpenOptions, create_dir_all, remove_file}; #[cfg(not(target_arch = "wasm32"))] use std::io::{Read, Seek, SeekFrom, Write}; #[cfg(not(target_arch = "wasm32"))] @@ -2326,17 +2326,7 @@ impl VfsService { } fn database_size(&self, meta: &DatabaseMeta) -> Result { - #[cfg(not(target_arch = "wasm32"))] - { - file_size(&meta.db_file_name) - } - #[cfg(target_arch = "wasm32")] - { - (self.database_handle)(meta.mount_id)? - .refresh_checksum_chunk(u64::MAX) - .map(|report| report.db_size) - .map_err(|error| error.to_string()) - } + self.database_store(meta)?.logical_size_bytes() } fn database_export_chunk( @@ -3869,6 +3859,11 @@ struct DatabaseCharge<'a> { computed_charge: i64, } +struct AppliedDatabaseCharge { + paid_cycles: i64, + balance_after_cycles: i64, +} + struct StorageChargeInput<'a> { database_id: &'a str, caller: &'a str, @@ -4024,17 +4019,14 @@ fn charge_database_update_in_tx( tx: &Transaction<'_>, charge: DatabaseCharge<'_>, ) -> Result<(), String> { - let balance = database_balance_for_update(tx, charge.database_id)?; - let paid_cycles = balance.max(0).min(charge.computed_charge); - let next = balance.max(0) - paid_cycles; - update_database_cycles_balance(tx, charge.database_id, next, charge.config, charge.now)?; + let applied = apply_database_update_charge(tx, &charge)?; insert_database_ledger( tx, DatabaseLedgerInsert { database_id: charge.database_id, kind: "charge", - amount_cycles: -paid_cycles, - balance_after_cycles: next, + amount_cycles: -applied.paid_cycles, + balance_after_cycles: applied.balance_after_cycles, payment_amount_e8s: None, caller: charge.caller, method: Some(charge.method), @@ -4047,6 +4039,41 @@ fn charge_database_update_in_tx( Ok(()) } +fn apply_database_update_charge( + tx: &Transaction<'_>, + charge: &DatabaseCharge<'_>, +) -> Result { + let min = cycles_to_i64(charge.config.min_update_cycles)?; + tx.query_row( + "WITH charge_input AS MATERIALIZED ( + SELECT min(max(balance_cycles, 0), ?2) AS paid_cycles, + max(balance_cycles, 0) - min(max(balance_cycles, 0), ?2) + AS balance_after_cycles + FROM database_cycle_accounts + WHERE database_id = ?1 + ) + UPDATE database_cycle_accounts + SET balance_cycles = (SELECT balance_after_cycles FROM charge_input), + suspended_at_ms = CASE + WHEN (SELECT balance_after_cycles FROM charge_input) >= ?3 THEN NULL + ELSE ?4 + END, + updated_at_ms = ?4 + WHERE database_id = ?1 AND EXISTS (SELECT 1 FROM charge_input) + RETURNING (SELECT paid_cycles FROM charge_input), balance_cycles", + params![charge.database_id, charge.computed_charge, min, charge.now], + |row| { + Ok(AppliedDatabaseCharge { + paid_cycles: crate::sqlite::row_get(row, 0)?, + balance_after_cycles: crate::sqlite::row_get(row, 1)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("database cycles account not found: {}", charge.database_id)) +} + fn compute_update_charge(cycles_delta: u128) -> Result { i64::try_from(cycles_delta).map_err(|_| "cycle charge exceeds i64 limit".to_string()) } @@ -4950,13 +4977,6 @@ fn role_allows(role: DatabaseRole, required_role: RequiredRole) -> bool { } } -#[cfg(not(target_arch = "wasm32"))] -fn file_size(path: &str) -> Result { - metadata(path) - .map(|metadata| metadata.len()) - .map_err(|error| error.to_string()) -} - #[cfg(test)] mod tests { use std::path::Path; diff --git a/crates/vfs_runtime/tests/database_service.rs b/crates/vfs_runtime/tests/database_service.rs index 52eaa1c..b9864cd 100644 --- a/crates/vfs_runtime/tests/database_service.rs +++ b/crates/vfs_runtime/tests/database_service.rs @@ -13,9 +13,9 @@ use vfs_runtime::{ }; use vfs_types::{ AppendNodeRequest, CyclesBillingConfigUpdate, DatabaseRole, DatabaseStatus, - DeleteDatabaseRequest, DeleteNodeRequest, KINIC_LEDGER_FEE_E8S, MkdirNodeRequest, - MoveNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, OpsAnswerSessionRequest, - SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, + DeleteDatabaseRequest, DeleteNodeRequest, EditNodeRequest, KINIC_LEDGER_FEE_E8S, + MkdirNodeRequest, MoveNodeRequest, NodeKind, OpsAnswerSessionCheckRequest, + OpsAnswerSessionRequest, SearchNodesRequest, SearchPreviewMode, SourceRunSessionCheckRequest, UrlIngestTriggerSessionCheckRequest, UrlIngestTriggerSessionRequest, WriteNodeRequest, WriteSourceForGenerationRequest, }; @@ -2330,6 +2330,20 @@ fn zero_cycle_charge_skips_cycle_ledger() { assert_eq!(entries[1].amount_cycles, -(purchased_cycles as i64)); } +#[test] +fn charge_database_update_reports_missing_cycle_account() { + let (service, _) = service_with_root(); + let config = service + .cycles_billing_config() + .expect("cycles config should load"); + + let error = service + .charge_database_update(&config, "missing", "owner", "write_node", 1, 1) + .expect_err("missing cycle account should fail"); + + assert!(error.contains("database cycles account not found: missing")); +} + #[test] fn creates_databases_with_unique_mount_ids() { let service = service(); @@ -2720,6 +2734,92 @@ fn tracks_logical_size_and_does_not_reuse_deleted_slots() { ); } +#[test] +fn logical_size_refreshes_after_node_mutations() { + let (service, root) = service_with_root(); + service + .create_database("alpha", "owner", 1) + .expect("alpha should create"); + + let written = service + .write_node( + "owner", + WriteNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + kind: NodeKind::File, + content: "alpha body".to_string(), + metadata_json: "{}".to_string(), + expected_etag: None, + }, + 2, + ) + .expect("write should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let appended = service + .append_node( + "owner", + AppendNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + content: "beta body".to_string(), + expected_etag: Some(written.node.etag), + separator: Some("\n".to_string()), + metadata_json: None, + kind: None, + }, + 3, + ) + .expect("append should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let edited = service + .edit_node( + "owner", + EditNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/a.md".to_string(), + old_text: "beta body".to_string(), + new_text: "gamma body".to_string(), + expected_etag: Some(appended.node.etag), + replace_all: false, + }, + 4, + ) + .expect("edit should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + let moved = service + .move_node( + "owner", + MoveNodeRequest { + database_id: "alpha".to_string(), + from_path: "/Wiki/a.md".to_string(), + to_path: "/Wiki/b.md".to_string(), + expected_etag: Some(edited.node.etag), + overwrite: false, + }, + 5, + ) + .expect("move should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); + + service + .delete_node( + "owner", + DeleteNodeRequest { + database_id: "alpha".to_string(), + path: "/Wiki/b.md".to_string(), + expected_etag: Some(moved.node.etag), + expected_folder_index_etag: None, + }, + 6, + ) + .expect("delete should succeed"); + assert!(database_index_row(&root, "alpha").2 > 0); +} + #[test] fn delete_database_allows_missing_file_but_rejects_other_remove_errors() { let (service, root) = service_with_root(); @@ -2756,6 +2856,30 @@ fn delete_database_allows_missing_file_but_rejects_other_remove_errors() { assert_eq!(database_index_row(&root, "remove_error").0, "active"); } +#[test] +fn begin_database_archive_rejects_missing_database_file_without_recreating_it() { + let (service, root) = service_with_root(); + service + .create_database("alpha", "owner", 1) + .expect("database should create"); + let db_file_name = service + .list_databases() + .expect("databases should load") + .into_iter() + .find(|meta| meta.database_id == "alpha") + .expect("database meta should exist") + .db_file_name; + std::fs::remove_file(&db_file_name).expect("database file should delete"); + + let error = service + .begin_database_archive("alpha", "owner", 2) + .expect_err("missing database file should fail archive"); + + assert!(!error.is_empty()); + assert_eq!(database_index_row(&root, "alpha").0, "active"); + assert!(!PathBuf::from(db_file_name).exists()); +} + #[test] fn begin_database_archive_updates_updated_at_ms() { let (service, root) = service_with_root(); diff --git a/crates/vfs_store/src/fs_store.rs b/crates/vfs_store/src/fs_store.rs index 490aeba..37b2ce1 100644 --- a/crates/vfs_store/src/fs_store.rs +++ b/crates/vfs_store/src/fs_store.rs @@ -9,6 +9,8 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg(not(target_arch = "wasm32"))] use std::path::{Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] +use crate::sqlite::OpenFlags; use crate::sqlite::{Connection, OptionalExtension, Transaction, params}; #[cfg(target_arch = "wasm32")] use ic_sqlite_vfs::{DbError, DbHandle}; @@ -166,6 +168,24 @@ impl FsStore { }) } + pub fn logical_size_bytes(&self) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let conn = Connection::open_with_flags( + &self.database_path, + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|error| error.to_string())?; + logical_size_bytes_for_conn(&conn) + } + #[cfg(target_arch = "wasm32")] + { + self.read_conn(logical_size_bytes_for_conn) + } + } + pub fn read_node(&self, path: &str) -> Result, String> { let normalized = normalize_node_path(path, false)?; self.read_conn(|conn| load_node(conn, &normalized)) @@ -1417,6 +1437,26 @@ fn count_nodes(conn: &Connection, kind: &str) -> Result { u64::try_from(count).map_err(|error| error.to_string()) } +fn logical_size_bytes_for_conn(conn: &Connection) -> Result { + let page_count = conn + .query_row("PRAGMA page_count", params![], |row| { + crate::sqlite::row_get::(row, 0) + }) + .map_err(|error| error.to_string())?; + let page_size = conn + .query_row("PRAGMA page_size", params![], |row| { + crate::sqlite::row_get::(row, 0) + }) + .map_err(|error| error.to_string())?; + let page_count = + u64::try_from(page_count).map_err(|_| "SQLite page_count is negative".to_string())?; + let page_size = + u64::try_from(page_size).map_err(|_| "SQLite page_size is negative".to_string())?; + page_count + .checked_mul(page_size) + .ok_or_else(|| "SQLite logical size exceeds u64".to_string()) +} + fn normalize_list_children_path(path: &str) -> Result { let trimmed = if path.len() > 1 && path.ends_with('/') { &path[..path.len() - 1] diff --git a/crates/vfs_store/src/sqlite.rs b/crates/vfs_store/src/sqlite.rs index 133d56a..a93212c 100644 --- a/crates/vfs_store/src/sqlite.rs +++ b/crates/vfs_store/src/sqlite.rs @@ -4,8 +4,8 @@ #[cfg(not(target_arch = "wasm32"))] pub(crate) use rusqlite::{ - Connection, Error, OptionalExtension, Params, Result, Row, Statement, Transaction, params, - params_from_iter, + Connection, Error, OpenFlags, OptionalExtension, Params, Result, Row, Statement, Transaction, + params, params_from_iter, }; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/vfs_store/tests/fs_store_basic.rs b/crates/vfs_store/tests/fs_store_basic.rs index 3ca5f0a..3380127 100644 --- a/crates/vfs_store/tests/fs_store_basic.rs +++ b/crates/vfs_store/tests/fs_store_basic.rs @@ -17,6 +17,50 @@ fn new_store() -> (tempfile::TempDir, FsStore) { (dir, store) } +#[test] +fn logical_size_bytes_rejects_missing_database_without_creating_file() { + let dir = tempdir().expect("temp dir should exist"); + let database_path = dir.path().join("missing.sqlite3"); + let store = FsStore::new(database_path.clone()); + + let error = store + .logical_size_bytes() + .expect_err("missing database should fail"); + + assert!(!error.is_empty()); + assert!(!database_path.exists()); +} + +#[test] +fn logical_size_bytes_uses_sqlite_page_size() { + let (_dir, store) = new_store(); + let database_path = store.database_path().to_path_buf(); + let empty_size = store + .logical_size_bytes() + .expect("empty logical size should load"); + + assert!(empty_size > 0); + assert_eq!( + empty_size, + std::fs::metadata(&database_path) + .expect("database file should exist") + .len() + ); + + write_file(&store, "/Wiki/size.md", None, 10); + let written_size = store + .logical_size_bytes() + .expect("written logical size should load"); + + assert!(written_size >= empty_size); + assert_eq!( + written_size, + std::fs::metadata(database_path) + .expect("database file should exist") + .len() + ); +} + fn old_fs_schema_store() -> (tempfile::TempDir, FsStore) { let dir = tempdir().expect("temp dir should exist"); let database_path = dir.path().join("wiki.sqlite3"); diff --git a/docs/DB_LIFECYCLE.md b/docs/DB_LIFECYCLE.md index ec212ac..c7638bc 100644 --- a/docs/DB_LIFECYCLE.md +++ b/docs/DB_LIFECYCLE.md @@ -70,7 +70,7 @@ Successful DB update calls are charged after execution. The charge is raw cycle cycles_delta ``` -Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. +Cycles are stored as raw integer cycles. The default purchase rate is `1 KINIC = 234_500_000_000 cycles` (`0.2345 Tcycle`), controlled by `cycles_per_kinic`. Before a metered update, the caller role is checked first, then the DB cycles balance must be at least `min_update_cycles` and the DB must not be suspended. Non-members receive access errors without learning cycles state. Metered update charge uses the current message instruction counter delta plus the 13-node update execution base fee, not same-message canister balance deltas. If the post-update charge exceeds the DB cycles balance, the remaining DB cycles are fully charged, the balance becomes `0`, and the DB is suspended. Storage billing settles every 24h from a canister timer, with controller-only `settle_database_storage_charges()` as recovery path. Only active DBs are charged. The 13-node subnet rate is fixed at `127_000 cycles / GiB / sec`: diff --git a/docs/payment.md b/docs/payment.md index 98dd684..71ca27a 100644 --- a/docs/payment.md +++ b/docs/payment.md @@ -132,7 +132,7 @@ approved_allowance_e8s = payment_amount_e8s + ledger_fee_e8s approve の transaction fee は wallet 残高から別途支払われる。approve は現在 allowance を `expected_allowance` として渡し、30 分後に expire する。approve 後に purchase が失敗した場合、UI は approval が expire まで残る旨を error に含める。 -UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。 +UI は `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` と request canister ID が一致しない場合に拒否する。Plug は VFS canister と KINIC ledger canister を whitelist して接続する。OISY は接続確認後に signer popup を閉じ、購入時に再度 signer を開いて同じ owner であることを確認する。OISY は ICRC wallet の call-canister 結果 certificate を検証し、method、canister、arg、reply を照合する。 ## ICRC-21 consent @@ -154,11 +154,11 @@ metered update は実行前に `prepare_metered_update` で認可と残高確認 `check_database_write_cycles(database_id)` は anonymous caller を明示的に拒否し、writer 以上の role と cycles 利用可能状態を確認する。 -canister の metered update wrapper は、更新前後の canister cycle balance 差分を `cycles_delta` として計算する。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 +canister の metered update wrapper は、更新前後の `performance_counter(InstructionCounter)` 差分に 13-node application subnet の update 実行 base fee `5_000_000 cycles` を加えた値を `cycles_delta` として計算する。`canister_cycle_balance` は同一 message 内の実行課金確定前の残高を返すため、update 本体の課金計測には使わない。更新関数が `Ok` を返した場合だけ、`charge_database_update` を実行する。 更新課金額は `cycles_delta` そのものである。`cycles_delta` が `i64` に収まらない場合は `cycle charge exceeds i64 limit` になる。`cycles_delta == 0` の場合、残高更新も ledger 記録も行わない。 -`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は実測請求額を記録する。 +`charge_database_update` は現在残高より請求額が大きい場合、残高を全徴収して `balance_after_cycles = 0` にし、DB を suspended にする。ledger の `amount_cycles` は実際に徴収した cycles、`cycles_delta` は update 実行 base fee と instruction 差分から計算した請求額を記録する。 課金成功時は残高から実徴収額を引く。更新後残高が `min_update_cycles` 未満なら `suspended_at_ms` を課金時刻に設定し、それ以上なら `NULL` にする。ledger には以下を記録する。 diff --git a/extensions/wiki-clipper/manifest.json b/extensions/wiki-clipper/manifest.json index ca03ce7..807827b 100644 --- a/extensions/wiki-clipper/manifest.json +++ b/extensions/wiki-clipper/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Kinic Wiki Clipper", - "version": "0.1.1", + "version": "0.1.2", "description": "Create Kinic Wiki pages from the current tab and export ChatGPT conversations.", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs5287sDaurUdgrnxKcx/A5JjcRpUFrxb5qXEvtkiO2STfEVsprr+uUltmecOlbHzt/8n15LxXPOyl3C9S2ZyArZWgUbOy0o3cCTDwLM80KkMj+x0hHGwC5KGAjZqOWnyQsFe6jonlQuTUye70xFodZdDfgU0V7SMjmrCFArIno+8A+3aVdODpcCMSkwiWaAFmCbvXeVFcAn70tcI0rJ/GXgnksU4gqwL6+0S5ZvLvhc7xRLNRjK+j2YXTRxxrGKQJ1ongCgJSgWzzSnJVOJ3t4K7ZosBlCZV6p/qTrY/4V2LC/XACRPctnKgNmD/9rvedIZdCVBMBYrfPOMSbteZeQIDAQAB", "action": { diff --git a/extensions/wiki-clipper/package-lock.json b/extensions/wiki-clipper/package-lock.json index a45c36c..aa8af3d 100644 --- a/extensions/wiki-clipper/package-lock.json +++ b/extensions/wiki-clipper/package-lock.json @@ -1,12 +1,12 @@ { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@icp-sdk/auth": "^5.0.0", "@icp-sdk/core": "^5.4.0", diff --git a/extensions/wiki-clipper/package.json b/extensions/wiki-clipper/package.json index 385da43..122c94e 100644 --- a/extensions/wiki-clipper/package.json +++ b/extensions/wiki-clipper/package.json @@ -1,6 +1,6 @@ { "name": "kinic-wiki-clipper", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/smoke/local_canister_post_upgrade.sh b/scripts/smoke/local_canister_post_upgrade.sh index c7ac346..0b909cc 100755 --- a/scripts/smoke/local_canister_post_upgrade.sh +++ b/scripts/smoke/local_canister_post_upgrade.sh @@ -104,7 +104,7 @@ export ICP_ENVIRONMENT export KINIC_LEDGER_CANISTER_ID export SMOKE_CYCLE_PURCHASE_E8S -scripts/local/deploy_wiki.sh +scripts/local/deploy_wiki.sh "$@" CANISTER_ID="$(resolve_canister_id)" export CANISTER_ID export REPLICA_HOST diff --git a/wikibrowser/app/cycles/cycles-client.tsx b/wikibrowser/app/cycles/cycles-client.tsx index bfa5bbe..505b540 100644 --- a/wikibrowser/app/cycles/cycles-client.tsx +++ b/wikibrowser/app/cycles/cycles-client.tsx @@ -4,8 +4,9 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { CheckCircle2, CircleAlert, Info, PlugZap, Wallet } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { parseKinicAmountE8sInput, parseCyclesTarget } from "@/lib/cycles-url"; import { connectOisyWallet, connectPlugWallet, purchaseCyclesWithOisy, purchaseCyclesWithPlug, type ConnectedOisyWallet, type ConnectedPlugWallet } from "@/lib/cycles-wallet"; import { formatTokenAmountFromE8s } from "@/lib/kinic-amount"; @@ -20,6 +21,7 @@ type CyclesClientProps = { }; export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesClientProps) { + const router = useRouter(); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState(null); const [provider, setProvider] = useState(null); @@ -53,12 +55,6 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli : null; const purchaseDisabled = !selectedProvider || Boolean(error) || Boolean(amountError) || busy; - useEffect(() => { - return () => { - if (oisyWallet) void oisyWallet.wallet.disconnect(); - }; - }, [oisyWallet]); - async function connect(nextProvider: CyclesProvider) { setStatus("connecting"); setProvider(nextProvider); @@ -105,6 +101,7 @@ export function CyclesClient({ canisterId, databaseId, initialKinic }: CyclesCli ); if (selectedProvider === "oisy") setOisyWallet(null); setStatus("success"); + router.replace("/"); } catch (cause) { setMessage(cause instanceof Error ? cause.message : String(cause)); setStatus("error"); diff --git a/wikibrowser/app/dashboard/dashboard-client.tsx b/wikibrowser/app/dashboard/dashboard-client.tsx index bc4fd8c..d693b07 100644 --- a/wikibrowser/app/dashboard/dashboard-client.tsx +++ b/wikibrowser/app/dashboard/dashboard-client.tsx @@ -314,7 +314,7 @@ export function DashboardDatabaseClient({ databaseId }: { databaseId: string }) ) : !databaseId ? (

Select a database to manage

-

Open the Database dashboard, then choose Access on a database row.

+

Open the Database dashboard, then choose Manage on a database row.

Open Database dashboard diff --git a/wikibrowser/app/home-ui.tsx b/wikibrowser/app/home-ui.tsx index 4ed7d25..b9a82d1 100644 --- a/wikibrowser/app/home-ui.tsx +++ b/wikibrowser/app/home-ui.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { BookOpen, Settings, Share2, Wallet } from "lucide-react"; import type { ReactNode } from "react"; -import { databaseCyclesView, databaseCyclesHref } from "@/lib/cycles-state"; +import { formatCycles as formatCycleBalance } from "@/lib/cycles"; +import { databaseCyclesView, databaseCyclesHref, type DatabaseCycleView } from "@/lib/cycles-state"; import type { CyclesBillingConfig, DatabaseSummary } from "@/lib/types"; import { isRoutableDatabaseId, publicDatabasePath, xShareDatabaseHref } from "@/lib/share-links"; @@ -77,7 +78,7 @@ export function DatabaseBody({ } return (
- +
); @@ -92,7 +93,6 @@ export function OfficialKinicWikiPanel() {

{OFFICIAL_KINIC_WIKI_DATABASE_NAME}

A canister-backed file-system wiki for agent memory: structured paths, raw sources, links, search, and safe edits.

Use the Chrome extension to capture ChatGPT conversations and active web pages into the same database.

-

{OFFICIAL_KINIC_WIKI_DATABASE_ID}

@@ -101,7 +101,7 @@ export function OfficialKinicWikiPanel() { - Access + Manage
@@ -120,7 +120,7 @@ function DatabaseSection({ title }: { cyclesConfig: CyclesBillingConfig | null; - description: string; + description?: string; emptyMessage: string; mode: "member" | "public"; publicError?: string | null; @@ -132,7 +132,7 @@ function DatabaseSection({ return (
{showTitle ?

{title}

: null} - {showTitle ?

{description}

: null} + {showTitle && description ?

{description}

: null} {publicError && mode === "public" ?

{publicError}

: null}

{emptyMessage}

@@ -143,7 +143,7 @@ function DatabaseSection({ {showTitle ? (

{title}

-

{description}

+ {description ?

{description}

: null} {publicError && mode === "public" ?

{publicError}

: null}
) : null} @@ -157,15 +157,15 @@ function DatabaseSection({ - + + - + - - {mode === "member" ? : null} - + {mode === "member" ? : null} + @@ -181,40 +181,34 @@ function DatabaseSection({ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { const active = isActiveRoutableDatabase(database); + const cycles = databaseCyclesView(database, cyclesConfig); return ( + - + - - + {mode === "member" ? ( ) : null} ); @@ -222,38 +216,34 @@ function DatabaseTableRow({ cyclesConfig, database, mode }: { cyclesConfig: Cycl function DatabaseMobileCard({ cyclesConfig, database, mode }: { cyclesConfig: CyclesBillingConfig | null; database: DatabaseRow; mode: "member" | "public" }) { const active = isActiveRoutableDatabase(database); + const cycles = databaseCyclesView(database, cyclesConfig); return (
-

{database.name}

- {mode === "member" && database.publicReadable ? Public : null} +

+ {active ? ( + + {database.name} + + ) : ( + {database.name} + )} +

+ {database.publicReadable ? : null}
-

{database.databaseId}

+ - - - + + +
- {active ? ( - } label="Open" /> - ) : null} - {active && mode === "member" && database.publicReadable ? ( - } label="Open public" /> - ) : null} - {mode === "member" ? ( - active ? ( - - Registry - - ) : null - ) : null} {mode === "member" ? ( - } label="Cycles" /> + } label="Top up" /> ) : null} {active && database.publicReadable ? : null} - } label="Access" /> + } label="Manage" />
); @@ -271,9 +261,13 @@ function ShareDatabaseLink({ database }: { database: DatabaseRow }) { ); } +function PublicBadge() { + return P; +} + function DatabaseActionLink({ ariaLabel, external = false, href, icon, label }: { ariaLabel?: string; external?: boolean; href: string; icon: ReactNode; label: string }) { const className = - "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-lg border border-line bg-white px-2.5 py-1.5 text-sm font-medium text-accent no-underline shadow-[0_4px_10px_#14142b0a] hover:border-accent hover:bg-accent hover:text-white"; + "inline-flex min-h-9 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-line bg-white px-2.5 py-1.5 text-sm font-medium text-accent no-underline shadow-[0_4px_10px_#14142b0a] hover:border-accent hover:bg-accent hover:text-white"; if (external) { return ( @@ -290,11 +284,39 @@ function DatabaseActionLink({ ariaLabel, external = false, href, icon, label }: ); } +function databaseStatusSummary(database: DatabaseRow, cycles: DatabaseCycleView): string { + const databaseStatus = formatStatus(database.status); + if (database.status === "pending") return "Pending · Needs cycles"; + if (!cycles.configAvailable) return `${databaseStatus} · Cycles unknown`; + if (database.status !== "active") return databaseStatus; + if (cycles.state === "suspended") return "Suspended"; + if (cycles.state === "low-balance") return "Low cycles"; + return "Active"; +} + +function databaseCyclesBalanceSummary(database: DatabaseRow): string { + const balance = parseCyclesBalance(database.cyclesBalance); + return balance === null ? "-" : formatCycleBalance(balance); +} + +function parseCyclesBalance(value: string | null | undefined): bigint | null { + if (!value || !/^[0-9]+$/.test(value)) return null; + return BigInt(value); +} + +function formatStatus(value: string): string { + return value + .split(/[_-]+/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1).toLowerCase()}`) + .join(" "); +} + function DatabaseCardMeta({ label, value }: { label: string; value: string }) { return (
{label}
-
{value}
+
{value}
); } @@ -337,7 +359,3 @@ function openDatabaseHref(database: DatabaseRow): string { const base = `/${encodeURIComponent(database.databaseId)}/Wiki`; return !database.member && database.publicReadable ? `${base}?read=anonymous` : base; } - -function openPublicDatabaseHref(database: DatabaseRow): string { - return publicDatabasePath(database.databaseId); -} diff --git a/wikibrowser/lib/cycles-wallet.ts b/wikibrowser/lib/cycles-wallet.ts index a88b587..db8fee5 100644 --- a/wikibrowser/lib/cycles-wallet.ts +++ b/wikibrowser/lib/cycles-wallet.ts @@ -115,7 +115,6 @@ type LedgerAccount = { }; export type ConnectedOisyWallet = { - wallet: CyclesPurchaseIcrcWallet; owner: string; }; @@ -165,19 +164,30 @@ class CyclesPurchaseIcrcWallet extends IcrcWallet { } } -export async function connectOisyWallet(): Promise { - const wallet = await CyclesPurchaseIcrcWallet.connect({ +function openOisyWallet(): Promise { + return CyclesPurchaseIcrcWallet.connect({ url: process.env.NEXT_PUBLIC_OISY_SIGNER_URL ?? DEFAULT_OISY_SIGNER_URL, host: process.env.NEXT_PUBLIC_WIKI_IC_HOST ?? "https://icp0.io" }); +} + +async function safeDisconnectOisyWallet(wallet: CyclesPurchaseIcrcWallet): Promise { + try { + await wallet.disconnect(); + } catch { + // Cleanup failures must not hide connect, approve, or purchase errors. + } +} + +export async function connectOisyWallet(): Promise { + const wallet = await openOisyWallet(); try { const accounts = await wallet.accounts(); const account = accounts[0]; if (!account) throw new Error("OISY account not found"); - return { wallet, owner: account.owner }; - } catch (cause) { - await wallet.disconnect(); - throw cause; + return { owner: account.owner }; + } finally { + await safeDisconnectOisyWallet(wallet); } } @@ -195,26 +205,35 @@ export async function connectPlugWallet(): Promise { export async function purchaseCyclesWithOisy(request: CyclesPurchaseRequest, connection: ConnectedOisyWallet): Promise { const prepared = await prepareCyclesPurchase(request, connection.owner); - const approveBlockIndex = await connection.wallet.approve({ - owner: connection.owner, - ledgerCanisterId: prepared.kinicLedgerCanisterId, - params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt), - options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } - }); - const purchase = await purchaseAfterApprove( - () => oisyCallCyclesPurchase(connection.wallet, connection.owner, request.canisterId, prepared.purchaseRequest), - { approveBlockIndex: approveBlockIndex.toString(), expiresAt: prepared.expiresAt } - ); - return { - provider: "oisy", - approveBlockIndex: approveBlockIndex.toString(), - approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), - purchasedCycles: formatRawCycles(BigInt(purchase.amountCycles)), - paymentAmountE8s: prepared.paymentAmountE8s.toString(), - transferFeeE8s: prepared.transferFeeE8s.toString(), - purchaseBlockIndex: purchase.blockIndex, - balanceCycles: purchase.balanceCycles ? formatRawCycles(BigInt(purchase.balanceCycles)) : null - }; + const wallet = await openOisyWallet(); + try { + const accounts = await wallet.accounts(); + const account = accounts[0]; + if (!account) throw new Error("OISY account not found"); + if (account.owner !== connection.owner) throw new Error("OISY owner changed; connect OISY again"); + const approveBlockIndex = await wallet.approve({ + owner: connection.owner, + ledgerCanisterId: prepared.kinicLedgerCanisterId, + params: approveParams(request.canisterId, prepared.approvedAllowanceE8s, prepared.currentAllowanceE8s, prepared.expiresAt), + options: { timeoutInMilliseconds: CALL_TIMEOUT_MS } + }); + const purchase = await purchaseAfterApprove( + () => oisyCallCyclesPurchase(wallet, connection.owner, request.canisterId, prepared.purchaseRequest), + { approveBlockIndex: approveBlockIndex.toString(), expiresAt: prepared.expiresAt } + ); + return { + provider: "oisy", + approveBlockIndex: approveBlockIndex.toString(), + approvedAllowanceE8s: prepared.approvedAllowanceE8s.toString(), + purchasedCycles: formatRawCycles(BigInt(purchase.amountCycles)), + paymentAmountE8s: prepared.paymentAmountE8s.toString(), + transferFeeE8s: prepared.transferFeeE8s.toString(), + purchaseBlockIndex: purchase.blockIndex, + balanceCycles: purchase.balanceCycles ? formatRawCycles(BigInt(purchase.balanceCycles)) : null + }; + } finally { + await safeDisconnectOisyWallet(wallet); + } } export async function purchaseCyclesWithPlug(request: CyclesPurchaseRequest, connection: ConnectedPlugWallet): Promise { diff --git a/wikibrowser/scripts/check-cycles.mjs b/wikibrowser/scripts/check-cycles.mjs index 502c724..24ab903 100644 --- a/wikibrowser/scripts/check-cycles.mjs +++ b/wikibrowser/scripts/check-cycles.mjs @@ -30,6 +30,7 @@ assert.match(client, /Connect OISY/); assert.match(client, /Connect Plug/); assert.match(client, /Purchase cycles with OISY/); assert.match(client, /Purchase cycles with Plug/); +assert.match(client, /router\.replace\("\/"\)/); assert.match(client, /const purchaseDisabled = !selectedProvider \|\| Boolean\(error\) \|\| Boolean\(amountError\) \|\| busy/); assert.match(client, /function WalletConnect/); assert.match(client, /onClick=\{\(\) => void purchase\(\)\}/); @@ -46,8 +47,11 @@ assert.match(wallet, /class CyclesPurchaseIcrcWallet extends IcrcWallet/); assert.match(wallet, /async callCyclesPurchase\(params: IcrcCallCanisterRequestParams\)/); assert.match(wallet, /export async function purchaseCyclesWithOisy\(request: CyclesPurchaseRequest, connection: ConnectedOisyWallet\)/); assert.match(wallet, /export async function purchaseCyclesWithPlug\(request: CyclesPurchaseRequest, connection: ConnectedPlugWallet\)/); -assert.match(connectOisy, /CyclesPurchaseIcrcWallet\.connect/); +assert.match(connectOisy, /openOisyWallet\(\)/); assert.match(connectOisy, /wallet\.accounts\(\)/); +assert.match(wallet, /async function safeDisconnectOisyWallet\(wallet: CyclesPurchaseIcrcWallet\): Promise/); +assert.match(wallet, /Cleanup failures must not hide connect, approve, or purchase errors\./); +assert.match(connectOisy, /safeDisconnectOisyWallet\(wallet\)/); assert.doesNotMatch(connectOisy, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); assert.match(connectPlug, /plug\.requestConnect\(\{\s*host:/); assert.doesNotMatch(connectPlug, /getCyclesBillingConfig|previewDatabaseCyclesPurchase|whitelist/); @@ -80,7 +84,11 @@ assert.match(wallet, /expected_allowance: \[expectedAllowanceE8s\]/); assert.match(wallet, /expires_at: \[expiresAt\]/); assert.match(wallet, /APPROVE_EXPIRES_IN_MS = 30 \* 60 \* 1000/); assert.match(wallet, /assertConfiguredCyclesCanister\(request\.canisterId\)/); -assert.match(wallet, /oisyCallCyclesPurchase\(connection\.wallet, connection\.owner, request\.canisterId, prepared\.purchaseRequest\)/); +assert.match(purchaseOisy, /openOisyWallet\(\)/); +assert.match(purchaseOisy, /account\.owner !== connection\.owner/); +assert.match(purchaseOisy, /safeDisconnectOisyWallet\(wallet\)/); +assert.match(purchaseOisy, /purchaseAfterApprove/); +assert.match(wallet, /oisyCallCyclesPurchase\(wallet, connection\.owner, request\.canisterId, prepared\.purchaseRequest\)/); assert.match(wallet, /sender: owner/); assert.match(wallet, /wallet response sender mismatch/); assert.match(wallet, /contentMap|Certificate|requestIdOf/); @@ -193,6 +201,7 @@ const clientModule = loadTsModule( "../app/cycles/cycles-client.tsx", { "next/link": { __esModule: true, default: () => null }, + "next/navigation": { useRouter: () => ({ replace: () => undefined }) }, "lucide-react": { CheckCircle2: () => null, CircleAlert: () => null, diff --git a/wikibrowser/scripts/check-dashboard.mjs b/wikibrowser/scripts/check-dashboard.mjs index d36afbd..6ddae8b 100644 --- a/wikibrowser/scripts/check-dashboard.mjs +++ b/wikibrowser/scripts/check-dashboard.mjs @@ -147,11 +147,13 @@ assert.match(homeUi, /publicDatabasePath\(OFFICIAL_KINIC_WIKI_DATABASE_ID\)/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(OFFICIAL_KINIC_WIKI_DATABASE_ID\)\}/); assert.match(homeUi, /My databases/); assert.match(homeUi, /Public databases/); +assert.doesNotMatch(homeUi, /Databases where your signed-in principal has a direct role\./); assert.match(homeUi, /No databases are linked to this principal\./); assert.match(homeUi, /No public databases are available\./); assert.match(homeUi, /publicError && mode === "public"/); -assert.match(homeUi, /Open public/); -assert.match(homeUi, /openPublicDatabaseHref/); +assert.doesNotMatch(homeUi, /PackageSearch/); +assert.doesNotMatch(homeUi, /Open public/); +assert.doesNotMatch(homeUi, /openPublicDatabaseHref/); assert.match(homeUi, /ShareDatabaseLink/); assert.match(homeUi, /Share2/); assert.match(homeUi, /xShareDatabaseHref/); @@ -159,11 +161,40 @@ assert.doesNotMatch(homeUi, /Archive/); assert.match(homeUi, /function isActiveRoutableDatabase\(database: DatabaseRow\): boolean/); assert.match(homeUi, /return database\.status === "active" && isRoutableDatabaseId\(database\.databaseId\);/); assert.match(homeUi, /const active = isActiveRoutableDatabase\(database\);/); -assert.match(homeUi, /
DatabaseNameID Role StatusLogical sizeSize CyclesOpen ShareSkillsAccessTop upManage
- {database.name} - {database.databaseId} - {mode === "member" && database.publicReadable ? Public : null} + {active ? ( + + {database.name} + + ) : ( + {database.name} + )} + {database.publicReadable ? : null}
{database.databaseId} {database.role}{database.status}{databaseStatusSummary(database, cycles)} {formatBytes(database.logicalSizeBytes)}{databaseCyclesView(database, cyclesConfig).summary} -
- {active ? } label="Open" /> : -} - {active && mode === "member" && database.publicReadable ? } label="Open public" /> : null} -
-
{databaseCyclesBalanceSummary(database)} {active && database.publicReadable ? : -} -
- {active ? ( - - Registry - - ) : null} - } label="Cycles" /> -
+ } label="Top up" />
- } label="Access" /> + } label="Manage" />
Access<\/th>/); +assert.match(homeUi, /Name<\/th>/); +assert.match(homeUi, /ID<\/th>/); +assert.match(homeUi, /Status<\/th>/); +assert.match(homeUi, /Size<\/th>/); +assert.match(homeUi, /Cycles<\/th>/); +assert.doesNotMatch(homeUi, /Open<\/th>/); +assert.doesNotMatch(homeUi, /Registry<\/th>/); +assert.match(homeUi, /Top up<\/th>/); +assert.match(homeUi, /Manage<\/th>/); +assert.doesNotMatch(homeUi, /Database<\/th>/); +assert.doesNotMatch(homeUi, /Logical size/); +assert.match(homeUi, //); +assert.match(homeUi, /function PublicBadge\(\)/); +assert.match(homeUi, /function databaseStatusSummary\(database: DatabaseRow, cycles: DatabaseCycleView\): string/); +assert.match(homeUi, /if \(database\.status !== "active"\) return databaseStatus/); +assert.match(homeUi, /return "Suspended"/); +assert.match(homeUi, /return "Low cycles"/); +assert.match(homeUi, /return "Active"/); +assert.match(homeUi, /return "Pending · Needs cycles"/); +assert.doesNotMatch(homeUi, /Active · Suspended/); +assert.doesNotMatch(homeUi, /Active · Low cycles/); +assert.match(homeUi, /Cycles unknown/); +assert.match(homeUi, /function databaseCyclesBalanceSummary\(database: DatabaseRow\): string/); +assert.match(homeUi, /formatCycleBalance\(balance\)/); +assert.doesNotMatch(homeUi, /formatCycleBalance\(balance\) \+ " cycles"/); +assert.doesNotMatch(homeUi, /databaseCyclesView\(database, cyclesConfig\)\.summary/); +assert.match(homeUi, //); +assert.doesNotMatch(homeUi, /} label="Open" \/>/); assert.match(homeUi, /\/dashboard\/\$\{encodeURIComponent\(database\.databaseId\)\}/); -assert.match(homeUi, /active && mode === "member" && database\.publicReadable/); +assert.doesNotMatch(homeUi, /active && mode === "member" && database\.publicReadable/); assert.match(homeUi, /active && database\.publicReadable \? /); -assert.match(homeUi, /active \? \(\n\s+/); +assert.doesNotMatch(homeUi, /label="Registry"/); +assert.match(homeUi, /label="Top up"/); +assert.doesNotMatch(homeUi, /\n]*label="Cycles"/); assert.match(homeUi, /href=\{`\/dashboard\/\$\{encodeURIComponent\(databaseId\)\}`\}/); assert.match(homeUi, /Manage reservation/); assert.doesNotMatch(homeUi, /href=\{`\/\$\{encodeURIComponent\(databaseId\)\}\/Wiki`\}/); diff --git a/wikibrowser/scripts/check-skill-registry.mjs b/wikibrowser/scripts/check-skill-registry.mjs index 8b35041..45bbd0d 100644 --- a/wikibrowser/scripts/check-skill-registry.mjs +++ b/wikibrowser/scripts/check-skill-registry.mjs @@ -147,7 +147,7 @@ assert.match(client, /previewProposal:[\s\S]*previewApplyProposalDiff[\s\S]*fals assert.match(client, /const databases = await listDatabasesAuthenticated\(canisterId, activeIdentity\)/); assert.match(client, /setCyclesConfig\(await getCyclesBillingConfig\(canisterId\)\)/); assert.doesNotMatch(client, /const \[databases, config\] = await Promise\.all/); -assert.match(homeUi, /href=\{`\/skills\/\$\{encodeURIComponent\(database\.databaseId\)\}`\}/); +assert.doesNotMatch(homeUi, /href=\{`\/skills\/\$\{encodeURIComponent\(database\.databaseId\)\}`\}/); assert.match(homePage, /DatabaseBody/); assert.match(dashboardClient, /href=\{`\/skills\/\$\{encodeURIComponent\(databaseId\)\}`\}/); assert.doesNotMatch(inspector, /skill-manifest/);