From 873b7e34df10533a76661193b8a06c941692e39e Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Wed, 7 Oct 2026 18:53:09 +0200 Subject: [PATCH 1/3] feat(package-api): tear down a Package before it leaves the build A publish that removes a table refuses while the table holds rows, so a disabled Package with rows stopped the next publish. Its Characters also stayed live in Core tables with no code to drive them. teardown_package runs on one Shard. It makes the Package's Characters Dormant Characters, empties every table the Package declares, deletes its Package Config, and records the Package in game_package_teardown. build.rs now lists each Package's tables in GAME_PACKAGES and wraps each Package registration, so a torn-down Package's hooks, tick passes, encounter handlers and client command stop until a build without it runs. The tick then forgets the record, so an enabled Package starts fresh. A Package's Characters are those on Accounts it owns on the Shard plus the guids its new game_package_characters! read returns. That read covers Characters that crossed from another Shard and Characters on Accounts made before Package-owned Accounts. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 + CONTEXT.md | 13 +++ docs/development-cli.md | 50 ++++++-- docs/package-api.md | 29 +++++ docs/schema.md | 4 +- module/build.rs | 176 +++++++++++++++++++++++++++-- module/src/hooks.rs | 1 + module/src/lib.rs | 18 ++- module/src/package_teardown.rs | 188 +++++++++++++++++++++++++++++++ module/tests/package_teardown.rs | 114 +++++++++++++++++++ 10 files changed, 576 insertions(+), 22 deletions(-) create mode 100644 module/src/package_teardown.rs create mode 100644 module/tests/package_teardown.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f7c3807..06f59d623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ and is not reconstructed here. Character with no Session. It applies the name, race and class Refusals of client creation, and puts the Character on an Account without credentials. `game_package_account` records which Package owns that Account, and the record stays when the Package is disabled. +- **Package Teardown.** `teardown_package` stops a Package on one Shard before it leaves the build. + It empties the Package's tables, deletes its Package Config and stops its registered code, so the + next publish can remove the tables. The Package's Characters become Dormant Characters: offline, + with no live entity, and with their Account and Character rows kept. A Package names Characters + Core cannot find through ownership with `game_package_characters!`. - **Meeting stones.** Using a dungeon's meeting stone puts a Character, or the Party it leads, in one realm-wide queue on Realm-core. Five Seekers for one dungeon and team form a Party, and a queued Party fills its open tank, healer and damage roles from Seekers on any World Shard. diff --git a/CONTEXT.md b/CONTEXT.md index 2272fdc1b..6785db157 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -140,6 +140,12 @@ credentials, so no login reaches it. `game_package_account` records the owning P the record outlives the Package when the Package is disabled. _Avoid_: bot account, account block +**Dormant Character**: +A Character whose Package was torn down. It is offline and has no live entity, Sessionless Action +Consent or pending Intent. Its Account and Character rows stay, by maintainer decision: turning a +Package off never deletes its Characters. A Package enabled again does not adopt them. +_Avoid_: deleted bot, orphan bot, frozen bot + **Alpha Test Tools**: Account-owned authority for a limited set of alpha testing dot-commands. The Gateway reads its current value from Realm-core for every command and conveys it to the Home Shard. The Module applies @@ -935,6 +941,13 @@ _Avoid_: config file, setting (unqualified), package setting The two directories that hold installed Packages. `packages/` holds the enabled ones, which the build compiles. `.lyracore/packages-disabled/` holds the disabled ones, which it cannot see. A Package's location IS its enabled state; no file records it, so nothing can disagree with the disk about what the next build compiles. `lyracore packages enable` and `lyracore packages disable` move one folder between the two. _Avoid_: registry, package list, enabled flag, state file +**Package Teardown**: +The Operator step that stops a Package on every Shard before it leaves the Package Inventory. It +makes the Package's Characters Dormant Characters, empties the Package's tables, deletes its Package +Config, and stops its registered code. A publish that removes a table refuses while the table holds +rows, so the Package's tables must be empty first. `lyracore packages disable` runs it. +_Avoid_: uninstall, package wipe, cleanup + **Reference Package**: The maintained, minimal Package at `packages/example/`, committed to the LyraCore repo and present in every checkout. It doubles as living documentation for a Package's shape and is the template `lyracore packages new` copies and renames. It is deliberately inert: Rust-only, one commented hook pattern, no gameplay behavior. _Avoid_: template package, sample package diff --git a/docs/development-cli.md b/docs/development-cli.md index 7a9efcf4c..2ddc2f6f3 100644 --- a/docs/development-cli.md +++ b/docs/development-cli.md @@ -63,7 +63,7 @@ lyracore packages add FOLDER|GIT-URL|NAME [--yes] lyracore packages build lyracore packages check lyracore packages config NAME [KEY [VALUE]] [--new] -lyracore packages disable NAME +lyracore packages disable NAME [--yes] lyracore packages enable NAME lyracore packages list lyracore packages new NAME @@ -96,7 +96,7 @@ lyracore update | `packages build` | regenerate the Module schema typings, typecheck every Datascript against them, then emit and validate each enabled Package's Package Delta and Script Artifact | | `packages check` | verify every enabled Package's generated artifact against its recorded Build Identity, regenerating the Module typings fresh | | `packages config` | read a Package's key-values, or write one to every Shard of the fixture topology | -| `packages disable` | move an enabled Package out of the build's sight, keeping it on disk | +| `packages disable` | run Package Teardown on every Shard, then move the Package out of the build's sight, keeping it on disk | | `packages enable` | move a disabled Package back into the build | | `packages list` | every installed Package: enabled or disabled, where it came from, and whether it has drifted | | `packages new` | scaffold a new Package offline, by copying and renaming the reference Package this checkout ships | @@ -311,7 +311,8 @@ in `datascripts/` is checkout-wide today, not per-Package. ## `packages enable`, `disable`, `remove` — taking a Package out of the build ```bash -./lyracore packages disable my-package # out of the build, still on disk +./lyracore packages disable my-package # tear down, then out of the build, still on disk +./lyracore packages disable my-package --yes # answer the teardown question in advance ./lyracore packages enable my-package # back into the build ./lyracore packages remove my-package # delete a disabled Package, asks first ./lyracore packages remove my-package --yes # answer the deletion question in advance @@ -322,20 +323,45 @@ in `datascripts/` is checkout-wide today, not per-Package. `disable` rename one folder between the two. Nothing can disagree with the filesystem about which Packages the next build compiles. -Both directories are on the same filesystem, so the rename is atomic and each verb is the other's -undo. That is why neither asks for confirmation. The Provenance Stamp lives inside the folder, so it -travels with the move and is never rewritten: a re-enabled Package still reports its Package Source -and still reads as `clean` rather than drifted. +Both directories are on the same filesystem, so the rename is atomic and each verb undoes the other's +move. The Provenance Stamp lives inside the folder, so it travels with the move and is never +rewritten: a re-enabled Package still reports its Package Source and still reads as `clean` rather +than drifted. `enable` never asks. `disable` asks before it runs Package Teardown, because teardown +deletes the Package's rows. Name collisions fail before anything moves, on the Rust identifier rather than the folder name. A disabled `foo_bar` cannot be enabled next to an enabled `foo-bar`, because both fold onto `pkg_foo_bar`. -**Before `disable` moves a Package, it reports the Module tables that Package registers.** Disabling -takes those tables out of the schema, so the next publish is a schema change that removes them. -`lyracore publish` never passes SpacetimeDB's destructive wipe flag, so a publish that would drop a -table still holding rows stops instead of deleting them. The report tells you that before you commit -to the move. It does not block it. +**`disable` runs Package Teardown before it moves anything.** Disabling takes the Package's tables +out of the schema, so the next publish removes them. SpacetimeDB refuses to remove a table that +still holds rows, and `lyracore publish` never passes the destructive wipe flag, so that publish +stops. Teardown prevents this. When the recorded dev stack is up, `disable` calls +`teardown_package` on every Shard of the recorded topology, then once more on each Shard. On each +Shard, teardown does these steps: + +- It empties the Package's tables and deletes its Package Config. +- It stops the Package's hooks and tick passes until a build without the Package runs. +- It makes the Package's Characters Dormant Characters. They go offline and lose their live + entities, and their Accounts and Characters stay. + +`disable` asks before the teardown, and `--yes` answers in advance. If a Shard refuses, nothing +moves. The usual Refusal is a Package Character that is crossing +between Shards. Re-run `disable` when the crossing settles. When the stack is down, `disable` moves +the folder without a teardown and reports the Package's tables. The next publish then stops if +those tables hold rows; enable the Package, start the stack, and disable it again. + +The off path for a Package on a running Realm: + +```bash +./lyracore packages disable playerbots # asks, tears down every Shard, then moves the folder +./lyracore publish # the Module without the Package; its empty tables go +./lyracore packages replay # removes the Package's Runtime Scripts from every Shard +./lyracore client sync # only when the Package ships client content +``` + +`packages enable` followed by `publish` brings the Package back fresh: empty tables and default +Package Config. Its Dormant Characters stay as they are, and the Package creates new ones. **`packages remove NAME` deletes, so it has gates.** It requires the Package to be disabled already, and points at `packages disable` when it is not: the build has to stop compiling a Package before diff --git a/docs/package-api.md b/docs/package-api.md index e267bc955..a8389ac5c 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -41,6 +41,7 @@ which must be written literally. | `crate::game_client_command!(PARSE, APPLY, REPLY)` | the single Package parser, admitted apply operation and reply command name for authenticated addon commands | | `crate::character_owned!(delete \| restamp \| transfer \| not_transported, ..)` | a Package table's character-keyed sweeps and its cross-shard transport arm | | `crate::encounter_package!(BINDING, fn NAME(ctx, instance_id, signal) { .. })` | encounter authority for one Encounter Binding | +| `crate::game_package_characters!(fn NAME(ctx) { .. })` | the guids of the Characters the Package controls on this Shard, read by Package Teardown | A Package table that is keyed by `character_guid` needs a `delete` marker and a transport arm. Without them a despawned character leaves rows behind, and a character that crosses a shard loses them. @@ -100,6 +101,34 @@ so it stays when the Package is disabled. Pass the Package's own name. The Character starts at level 1 at its race and class start position, with zero gender and appearance bytes. The Package places it, levels it and builds its live entity. +### Package Teardown + +`teardown_package(package_name)` is an Operator reducer. Run it on every Shard of the Realm before a +Package leaves the build, then run it once more on each Shard. The second pass catches a Character +that crossed into a Shard that was already torn down. `lyracore packages disable` runs both passes. +In one transaction, teardown does these steps: + +- It makes each Character of the Package a Dormant Character. The Character goes offline and loses + its live entity, its Sessionless Action Consent and its pending Transfer and Group Intents. Its + Account and Character rows stay. +- It empties every table the Package declares. +- It deletes the Package's Package Config rows. +- It stops every hook, tick pass, encounter handler and client command the Package registered. + +The Characters of a Package are the Characters on Accounts it owns on this Shard, plus the guids its +`game_package_characters!` read returns. Register the read when Core cannot find every Character +through ownership. Two cases need it: a Character that crossed from another Shard, and a Character +on an Account made before Package-owned Accounts existed. A Character with a World Session is never +made Dormant. + +Teardown refuses while one of these Characters has a Transfer escrow row or a claimed Transfer +Intent on the Shard, because the escrow names the Package's tables. A Refusal writes nothing. +Retry when the crossing settles. + +A Package stays stopped until the first tick of a build that does not compile it. Enabling it again +then starts it fresh: empty tables, default Package Config, and no Characters from before. Its old +Characters stay Dormant. + ### Encounter kernel `crate::encounter` holds the encounter state machine and the choreography verbs a Package drives it diff --git a/docs/schema.md b/docs/schema.md index 51af49010..89f275735 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -61,7 +61,7 @@ rather than trust the numbers below, which drift on every schema change. The pat undercounts. The second command lists the tables per file, which is what the rows below add up: ```bash -grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 287 on 2026-10-07 +grep -rEn '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | wc -l # 288 on 2026-10-07 grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ':0$' # tables per file ``` @@ -89,7 +89,7 @@ grep -rEc '^#\[(spacetimedb::)?table\(' module/src --include='*.rs' | grep -v ': | Gateway leases | 3 | 0 | `gw.rs` | | Taxi | 5 | 1 | `taxi.rs` | | Weather | 3 | 1 | `weather.rs` | -| Packages | 4 | 1 | `package_account.rs`, `package_config.rs`, `package_import.rs`, `script_binding.rs` | +| Packages | 5 | 1 | `package_account.rs`, `package_config.rs`, `package_import.rs`, `package_teardown.rs`, `script_binding.rs` | | Event GC | 1 | 0 | `gc.rs` | | Config / static data / diagnostics | 22 | 21 | `config.rs`, `gm.rs`, `faction.rs`, `skilldata.rs`, `stats.rs`, `import_meta.rs`, `debug/*` | diff --git a/module/build.rs b/module/build.rs index d8eb3e250..5bb9c50c7 100644 --- a/module/build.rs +++ b/module/build.rs @@ -31,7 +31,9 @@ //! `GAME_TICK_PASSES` (periodic passes run by the core scheduler tick) and one //! `GAME_HOOKS_` array per known notify-hook event, dispatched at the core chokepoints //! (see `src/hooks.rs`), the optional Package client-command handler, and the map-scoped -//! encounter authority registry. +//! encounter authority registry. Each Package registration is wrapped so it stops once Package +//! Teardown has run (`src/package_teardown.rs`). `GAME_PACKAGES` lists every compiled Package +//! with its `#[table]` accessors and its `game_package_characters!` read, for that teardown. //! - `hook_dispatch.rs` — from `HOOK_EVENTS` below: the `payload_for` alias mod //! and one `fire_*` fn per event, included INSIDE `src/hooks.rs` so the paths every chokepoint //! already uses (`hooks::fire_*`, `hooks::payload_for::*`) are unchanged. This is what keeps the @@ -544,7 +546,8 @@ fn main() { pub const GAME_TICK_PASSES: &[(&str, fn(&spacetimedb::ReducerContext))] = &[\n", ); for path in ®istries.tick_passes { - out.push_str(&format!(" (\"{path}\", {path}),\n")); + let pass = gated(path, "ctx: &spacetimedb::ReducerContext", "ctx", ""); + out.push_str(&format!(" (\"{path}\", {pass}),\n")); } out.push_str("];\n"); for HookEvent { @@ -563,7 +566,11 @@ fn main() { event.to_uppercase() )); for path in &hooks { - out.push_str(&format!(" {path},\n")); + let params = format!("ctx: &spacetimedb::ReducerContext, payload: &{payload_ty}"); + out.push_str(&format!( + " {},\n", + gated(path, ¶ms, "ctx, payload", "") + )); } out.push_str("];\n"); } @@ -571,8 +578,14 @@ fn main() { "pub const GAME_ENCOUNTER_PACKAGES: &[(crate::encounter::EncounterBinding, crate::encounter::EncounterPackageHandler)] = &[\n", ); for (binding, path) in ®istries.encounter_packages { + let handler = gated( + path, + "ctx: &spacetimedb::ReducerContext, instance_id: u64, signal: crate::encounter::EncounterSignal", + "ctx, instance_id, signal", + "Ok(())", + ); out.push_str(&format!( - " (crate::encounter::EncounterBinding::{binding}, {path}),\n" + " (crate::encounter::EncounterBinding::{binding}, {handler}),\n" )); } out.push_str("];\n"); @@ -582,9 +595,17 @@ fn main() { } out.push_str("];\n"); match registries.client_commands.first() { - Some((parse, apply, reply)) => out.push_str(&format!( - "pub const GAME_CLIENT_COMMAND: Option = Some(crate::bridge::ClientCommandHandler {{ parse: {parse}, apply: {apply}, reply: {reply} }});\n" - )), + Some((parse, apply, reply)) => { + let apply = gated( + apply, + "ctx: &spacetimedb::ReducerContext, command: &crate::bridge::AdmittedClientCommand", + "ctx, command", + "crate::bridge::CommandOutcome::Suppressed", + ); + out.push_str(&format!( + "pub const GAME_CLIENT_COMMAND: Option = Some(crate::bridge::ClientCommandHandler {{ parse: {parse}, apply: {apply}, reply: {reply} }});\n" + )) + } None => out.push_str( "pub const GAME_CLIENT_COMMAND: Option = None;\n", ), @@ -597,6 +618,40 @@ fn main() { out.push_str(&format!(" \"{event}\",\n")); } out.push_str("];\n"); + registries.package_characters.sort(); + for duplicate in registries.package_characters.windows(2) { + if duplicate[0].0 == duplicate[1].0 { + panic!( + "build.rs: Package {} registers `game_package_characters!` twice: {} and {}", + duplicate[0].0, duplicate[0].1, duplicate[1].1 + ); + } + } + out.push_str("pub const GAME_PACKAGES: &[crate::package_teardown::InstalledPackage] = &[\n"); + for (ident, _) in &pkg_mods { + let mut tables: Vec<&str> = registries + .package_tables + .iter() + .filter(|(package, _)| package == ident) + .map(|(_, table)| table.as_str()) + .collect(); + tables.sort_unstable(); + tables.dedup(); + let characters = registries + .package_characters + .iter() + .find(|(package, _)| package == ident) + .map_or("None".to_string(), |(_, path)| format!("Some({path})")); + out.push_str(&format!( + " crate::package_teardown::InstalledPackage {{ name: \"{ident}\", tables: &[{}], characters: {characters} }},\n", + tables + .iter() + .map(|table| format!("\"{table}\"")) + .collect::>() + .join(", ") + )); + } + out.push_str("];\n"); write_out(&out_dir, "package_registries.rs", &out); // ---- hook_dispatch.rs ---- included INSIDE src/hooks.rs, so `payload_for` and the @@ -653,6 +708,67 @@ struct Registries { hooks: Vec<(String, String)>, // (event, fully-qualified fn path) encounter_packages: Vec<(String, String)>, // (binding variant, fully-qualified fn path) client_commands: Vec<(String, String, String)>, // (parser, admitted apply, reply name) paths + package_tables: Vec<(String, String)>, // (package ident, table accessor) + package_characters: Vec<(String, String)>, // (package ident, fully-qualified fn path) +} + +/// The registry entry for `path`. A Package's fn is wrapped so it stops running once Package +/// Teardown has run for that Package; the wrapper then returns `otherwise`. +fn gated(path: &str, params: &str, args: &str, otherwise: &str) -> String { + match path + .strip_prefix("crate::pkg_") + .and_then(|rest| rest.split("::").next()) + { + Some(package) => format!( + "|{params}| if crate::package_teardown::runs(ctx, \"{package}\") {{ {path}({args}) }} else {{ {otherwise} }}" + ), + None => path.to_string(), + } +} + +/// The accessor of every `#[table(..)]` and `#[spacetimedb::table(..)]` in stripped source. +/// Only the attribute's top-level `accessor`, so an index's own `accessor = ..` is never taken. +fn table_accessors(content: &str) -> Vec { + let mut accessors = Vec::new(); + for (start, _) in content.match_indices("#[") { + let attribute = content[start + 2..].trim_start(); + let Some(args) = attribute + .strip_prefix("table") + .or_else(|| attribute.strip_prefix("spacetimedb::table")) + .and_then(|rest| rest.trim_start().strip_prefix('(')) + else { + continue; + }; + let mut depth = 0usize; + let mut item_start = 0usize; + for (index, c) in args.char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' if depth == 0 => { + accessors.extend(accessor_item(&args[item_start..index])); + break; + } + ')' | ']' => depth -= 1, + ',' if depth == 0 => { + accessors.extend(accessor_item(&args[item_start..index])); + item_start = index + 1; + } + _ => {} + } + } + } + accessors +} + +fn accessor_item(item: &str) -> Option { + let value = item + .trim() + .strip_prefix("accessor")? + .trim_start() + .strip_prefix('=')? + .trim(); + (!value.is_empty() && value.chars().all(|c| c.is_alphanumeric() || c == '_')) + .then(|| value.to_string()) } /// The table accessor a transport arm's fully-qualified fn path names: the same @@ -1228,6 +1344,32 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: } }); + if let Some(package) = prefix.strip_prefix("crate::pkg_") { + for table in table_accessors(&content) { + reg.package_tables.push((package.to_string(), table)); + } + } + + scan_marker(&content, file, "game_package_characters!", |head, line| { + match (try_match_tick_pass(head), prefix.strip_prefix("crate::pkg_")) { + (Some(name), Some(package)) => { + check_facade_reexport(file, scan_root, in_package, &name); + reg.package_characters + .push((package.to_string(), format!("{prefix}::{name}"))); + } + (Some(_), None) => panic!( + "build.rs: `game_package_characters!` in {}:{line} is core code; only a Package names its Characters", + file.display() + ), + (None, _) => panic!( + "build.rs: malformed `game_package_characters!` marker in {}:{line} — expected exactly \ + `game_package_characters!(fn NAME(ctx) {{ .. }})`. A marker must never be silently \ + skipped.", + file.display() + ), + } + }); + scan_marker( &content, file, @@ -1922,6 +2064,26 @@ mod package_api_lint_tests { ); } + #[test] + fn teardown_finds_each_table_by_its_own_accessor() { + let source = "#[table(accessor = pkg_demo_one, public)]\nstruct One;\n\ + #[spacetimedb::table(\n index(accessor = by_due, btree(columns = [due, id])),\n accessor = pkg_demo_two,\n)]\nstruct Two;\n\ + #[derive(Clone)]\n#[tables(accessor = not_a_table)]\nstruct Three;\n"; + assert_eq!(table_accessors(source), ["pkg_demo_one", "pkg_demo_two"]); + } + + #[test] + fn a_package_registration_stops_after_teardown_and_core_code_never_does() { + assert_eq!( + gated("crate::pkg_demo::pass", "ctx: &C", "ctx", ""), + "|ctx: &C| if crate::package_teardown::runs(ctx, \"demo\") { crate::pkg_demo::pass(ctx) } else { }" + ); + assert_eq!( + gated("crate::hooks::pass", "ctx: &C", "ctx", ""), + "crate::hooks::pass" + ); + } + fn reported(source: &str) -> Vec { reported_at_depth(source, 0) } diff --git a/module/src/hooks.rs b/module/src/hooks.rs index a7c6429a2..9b8903428 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -261,6 +261,7 @@ include!(concat!(env!("OUT_DIR"), "/hook_dispatch.rs")); /// Called at the END of `tick_creatures` every tick (0.5s), after all core passes — see the cadence /// note in the `game_tick_pass` macro doc. pub(crate) fn run_package_tick_passes(ctx: &ReducerContext) { + crate::package_teardown::forget_removed_packages(ctx); for (_name, f) in crate::GAME_TICK_PASSES { f(ctx); } diff --git a/module/src/lib.rs b/module/src/lib.rs index 8ac8f0203..f58a5616f 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -238,6 +238,20 @@ macro_rules! game_hook { }; } +/// Names the Characters a Package controls on this Shard, so Package Teardown can make each one a +/// Dormant Character. Core already finds Characters on Accounts the Package owns here; this adds +/// the ones it cannot attribute, such as a Character that crossed from another Shard. Package +/// files only, at most once per Package. +/// +/// `game_package_characters!(fn () { })` — expands to +/// `pub(crate) fn (ctx: &ReducerContext) -> Vec`. +#[macro_export] +macro_rules! game_package_characters { + (fn $name:ident($ctx:ident) $body:block) => { + pub(crate) fn $name($ctx: &spacetimedb::ReducerContext) -> Vec $body + }; +} + /// Installs the one Package-owned parser and admitted apply operation for addon client commands. /// `REPLY` names a `&'static str` const: the addon command that carries each outcome to the issuer. /// Core authenticates the issuer and Gateway certifies realm-wide party authority before apply. @@ -275,7 +289,7 @@ include!(concat!(env!("OUT_DIR"), "/package_mods.rs")); // GENERATED by module/build.rs from package marker invocations. // Defines `GAME_TICK_PASSES: &[(&str, fn(&ReducerContext))]` (name string kept for logging) // and one `GAME_HOOKS_: &[fn(&ReducerContext, &Payload)]` per event in build.rs's -// HOOK_EVENTS catalog, plus the map-scoped encounter authority registry. +// HOOK_EVENTS catalog, plus the map-scoped encounter authority registry and `GAME_PACKAGES`. include!(concat!(env!("OUT_DIR"), "/package_registries.rs")); mod account_ownership; @@ -381,6 +395,8 @@ mod package_fixture; /// per-Package provenance that records what landed. Not re-exported below, for the same reason /// `import_meta` is not: nothing outside this module reads its table. mod package_import; +/// Package Teardown: the Operator step that stops a Package before it leaves the build. +mod package_teardown; /// The test-only Package API root for Package unit tests. Not re-exported below, for the same /// reason as `package_fixture`. #[cfg(test)] diff --git a/module/src/package_teardown.rs b/module/src/package_teardown.rs new file mode 100644 index 000000000..3916a0d45 --- /dev/null +++ b/module/src/package_teardown.rs @@ -0,0 +1,188 @@ +//! Package Teardown: the Operator step that stops one Package on one Shard before the Package +//! leaves the build. +//! +//! A publish that removes a table refuses while the table holds rows. So before a Package's folder +//! moves, its tables must be empty, and its code must not write them again before the publish. +//! Teardown empties every table the Package declares, deletes its Package Config, and makes its +//! Characters Dormant Characters. It then records the Package as torn down, which stops every +//! registration the Package made (`build.rs` wraps each one in [`runs`]). The record goes away by +//! itself on the first tick of a build that no longer compiles the Package, so enabling the Package +//! again starts it fresh. + +use spacetimedb::{reducer, table, ReducerContext, Table}; + +use crate::package_account::game_package_account; +use crate::package_config::game_package_config; +use crate::sessionless::game_sessionless_action_consent; +use crate::transfer::{game_bot_transfer_intent, game_transfer_in, game_transfer_out}; +use crate::{game_character, game_world_entity}; + +/// One row per Package torn down on this Shard. Private. [server] +#[table(accessor = game_package_teardown)] +pub struct PackageTeardown { + #[primary_key] + pub package_name: String, +} + +/// One compiled Package, as `build.rs` generates it into `GAME_PACKAGES`. +pub struct InstalledPackage { + /// The Package's Rust identifier: its folder name with `-` folded to `_`. + pub name: &'static str, + /// Every table the Package declares. Debug-only tables are absent from a release build. + pub tables: &'static [&'static str], + /// The Package's `game_package_characters!` read, when it registers one. + pub characters: Option Vec>, +} + +/// Whether a Package's registered code may run on this Shard. +#[cfg_attr(not(has_packages), allow(dead_code))] // package-only caller — see `build.rs` +pub(crate) fn runs(ctx: &ReducerContext, package: &str) -> bool { + let torn_down = ctx.db.game_package_teardown(); + torn_down.count() == 0 || torn_down.package_name().find(package.to_string()).is_none() +} + +/// Forget the teardown of a Package this build no longer compiles. Runs every scheduler tick. +pub(crate) fn forget_removed_packages(ctx: &ReducerContext) { + let torn_down = ctx.db.game_package_teardown(); + if torn_down.count() == 0 { + return; + } + for row in torn_down.iter().collect::>() { + if !crate::GAME_PACKAGES + .iter() + .any(|package| package.name == row.package_name) + { + torn_down.package_name().delete(row.package_name); + } + } +} + +/// Tear down `package_name` on this Shard. Idempotent; run it on every Shard of the Realm, then +/// once more on each, so a Character that crossed into an already torn-down Shard is caught. A +/// Refusal writes nothing. +#[reducer] +pub fn teardown_package(ctx: &ReducerContext, package_name: String) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let ident = package_name.replace('-', "_"); + let package = crate::GAME_PACKAGES + .iter() + .find(|package| package.name == ident) + .ok_or_else(|| format!("{package_name} is not a Package this build compiles"))?; + let characters = package_characters(ctx, package, &package_name); + if let Some(guid) = characters.iter().find(|&&guid| crossing(ctx, guid)) { + return Err(format!( + "Character {guid} is crossing between Shards; retry the teardown once it arrives" + )); + } + + // First, so the Package's own hooks stay silent while its Characters leave the world. + if ctx + .db + .game_package_teardown() + .package_name() + .find(ident.clone()) + .is_none() + { + ctx.db.game_package_teardown().insert(PackageTeardown { + package_name: ident.clone(), + }); + } + for guid in characters { + make_dormant(ctx, guid); + } + for table in package.tables { + clear_table(table)?; + } + let config = ctx.db.game_package_config(); + for row in config + .by_package_key() + .filter(package_name.as_str()) + .collect::>() + { + config.id().delete(row.id); + } + spacetimedb::log::info!("teardown_package: {package_name} torn down"); + Ok(()) +} + +/// The Characters on Accounts the Package owns here, plus the ones it names itself. +fn package_characters( + ctx: &ReducerContext, + package: &InstalledPackage, + package_name: &str, +) -> Vec { + let mut guids: Vec = package.characters.map_or_else(Vec::new, |read| read(ctx)); + for account in ctx + .db + .game_package_account() + .package_name() + .filter(package_name) + { + guids.extend( + ctx.db + .game_character() + .by_account() + .filter(account.account_id) + .map(|character| character.guid), + ); + } + guids.sort_unstable(); + guids.dedup(); + guids +} + +/// An escrow row or a claimed Transfer Intent. The escrow blob names the Package's tables, so it +/// must settle while those tables still exist. +fn crossing(ctx: &ReducerContext, guid: u64) -> bool { + ctx.db + .game_transfer_out() + .by_character() + .filter(guid) + .next() + .is_some() + || ctx + .db + .game_transfer_in() + .by_character() + .filter(guid) + .next() + .is_some() + || ctx + .db + .game_bot_transfer_intent() + .by_bot() + .filter(guid) + .any(|intent| intent.claim_token != 0) +} + +/// Offline, with no live entity, no consent and no pending Intent. The Account and Character rows +/// stay. A Character with a World Session is a player's, so it is left alone. +fn make_dormant(ctx: &ReducerContext, guid: u64) { + if crate::helpers::character_by_guid(ctx, guid).is_none_or(|character| character.online) { + return; + } + let intents = ctx.db.game_bot_transfer_intent(); + for intent in intents.by_bot().filter(guid).collect::>() { + intents.id().delete(intent.id); + } + crate::group::clear_unclaimed_group_intents(ctx, guid); + ctx.db + .game_sessionless_action_consent() + .character_guid() + .delete(guid); + if let Some(entity) = ctx.db.game_world_entity().guid().find(guid) { + crate::world::remove_live_character(ctx, entity); + } +} + +/// Empty one table by name. A table this build does not compile has nothing to empty. +fn clear_table(name: &str) -> Result<(), String> { + use spacetimedb::sys::{datastore_clear, table_id_from_name, Errno}; + match table_id_from_name(name) { + Ok(id) => datastore_clear(id) + .map(|_| ()) + .map_err(|error| format!("could not empty {name}: {error}")), + Err(error) if error == Errno::NO_SUCH_TABLE => Ok(()), + Err(error) => Err(format!("could not find {name}: {error}")), + } +} diff --git a/module/tests/package_teardown.rs b/module/tests/package_teardown.rs new file mode 100644 index 000000000..605dce2f7 --- /dev/null +++ b/module/tests/package_teardown.rs @@ -0,0 +1,114 @@ +//! `teardown_package` against a real Shard, with the reference Package as the Package torn down. + +mod support; + +use support::Standalone; + +const PACKAGE: &str = "example"; + +fn arg(value: &str) -> String { + serde_json::to_string(value).expect("a string encodes as JSON") +} + +fn stage(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard +} + +fn count(shard: &Standalone, query: &str) -> usize { + shard.query_rows(query).len() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn teardown_leaves_a_package_character_dormant_and_forgets_the_package_config() { + let shard = stage("package-teardown-dormant"); + shard.assert_call( + "debug_create_package_character", + &[&arg(PACKAGE), &arg("Sleeper"), "1", "1"], + ); + let character = + shard.query_rows("SELECT guid, account_id FROM game_character WHERE name = 'Sleeper'"); + let guid = character[0]["guid"].clone(); + let account_id = character[0]["account_id"].clone(); + shard.assert_call("debug_spawn_player_entity", &[&guid]); + shard.assert_call("debug_set_sessionless_action_consent", &[&guid, "true"]); + shard.assert_call( + "debug_emit_sessionless_group_intent", + &[&guid, "1", "false"], + ); + shard.assert_call( + "set_package_config", + &[&arg(PACKAGE), &arg("greeting"), &arg("hello"), "true"], + ); + + shard.assert_call("teardown_package", &[&arg(PACKAGE)]); + + for (what, query) in [ + ("live entity", format!("SELECT guid FROM game_world_entity WHERE guid = {guid}")), + ( + "Sessionless Action Consent", + format!("SELECT character_guid FROM game_sessionless_action_consent WHERE character_guid = {guid}"), + ), + ( + "Group Intent", + format!("SELECT id FROM game_bot_invite_intent WHERE inviter_guid = {guid}"), + ), + ( + "Package Config", + format!("SELECT id FROM game_package_config WHERE package_name = '{PACKAGE}'"), + ), + ] { + assert_eq!(count(&shard, &query), 0, "teardown left the {what}"); + } + let dormant = shard.query_rows(&format!( + "SELECT online FROM game_character WHERE guid = {guid}" + )); + assert_eq!(dormant.len(), 1, "teardown deleted the Character"); + assert_eq!(dormant[0]["online"], "false"); + assert_eq!( + count( + &shard, + &format!("SELECT id FROM game_account WHERE id = {account_id}") + ), + 1, + "teardown deleted the Package-owned Account" + ); + assert_eq!( + count( + &shard, + &format!( + "SELECT package_name FROM game_package_teardown WHERE package_name = '{PACKAGE}'" + ) + ), + 1, + "teardown did not record the Package" + ); + + // Idempotent: a second pass over the same Shard finds nothing left to do. + shard.assert_call("teardown_package", &[&arg(PACKAGE)]); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn teardown_refuses_a_package_this_build_does_not_compile() { + let shard = stage("package-teardown-unknown"); + let output = shard.call("teardown_package", &[&arg("not-installed")]); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success(), + "an unknown Package was torn down: {text}" + ); + assert!(text.contains("not a Package this build compiles"), "{text}"); + assert_eq!( + count(&shard, "SELECT package_name FROM game_package_teardown"), + 0 + ); +} From 1bf7932bb144cdeb89a9c67d8c4983d857d44dd7 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Wed, 7 Oct 2026 19:27:16 +0200 Subject: [PATCH 2/3] chore(gateway): regenerate bindings for Package Teardown Co-Authored-By: Claude Opus 5.5 (1M context) --- .../bindings/game_package_teardown_table.rs | 230 ++++++++++++++++++ gateway/src/stdb/bindings/mod.rs | 39 +++ .../stdb/bindings/package_teardown_type.rs | 49 ++++ .../stdb/bindings/teardown_package_reducer.rs | 68 ++++++ 4 files changed, 386 insertions(+) create mode 100644 gateway/src/stdb/bindings/game_package_teardown_table.rs create mode 100644 gateway/src/stdb/bindings/package_teardown_type.rs create mode 100644 gateway/src/stdb/bindings/teardown_package_reducer.rs diff --git a/gateway/src/stdb/bindings/game_package_teardown_table.rs b/gateway/src/stdb/bindings/game_package_teardown_table.rs new file mode 100644 index 000000000..16bc9e7e7 --- /dev/null +++ b/gateway/src/stdb/bindings/game_package_teardown_table.rs @@ -0,0 +1,230 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::package_teardown_type::PackageTeardown; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_package_teardown`. +/// +/// Obtain a handle from the [`GamePackageTeardownTableAccess::game_package_teardown`] method on [`super::RemoteTables`], +/// like `ctx.db.game_package_teardown()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_package_teardown().on_insert(...)`. +pub struct GamePackageTeardownTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_package_teardown`. +pub struct GamePackageTeardownTableAccessor; + +impl __sdk::TableAccessor for GamePackageTeardownTableAccessor { + type Row = PackageTeardown; + type Handle<'db> = GamePackageTeardownTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_package_teardown() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_package_teardown`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GamePackageTeardownTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GamePackageTeardownTableHandle`], which mediates access to the table `game_package_teardown`. + fn game_package_teardown(&self) -> GamePackageTeardownTableHandle<'_>; +} + +impl GamePackageTeardownTableAccess for super::RemoteTables { + fn game_package_teardown(&self) -> GamePackageTeardownTableHandle<'_> { + GamePackageTeardownTableHandle { + imp: self + .imp + .get_table::("game_package_teardown"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GamePackageTeardownInsertCallbackId(__sdk::CallbackId); +pub struct GamePackageTeardownDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GamePackageTeardownTableHandle<'ctx> { + type Row = PackageTeardown; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GamePackageTeardownTableHandle<'ctx> { + type Row = PackageTeardown; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GamePackageTeardownInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownInsertCallbackId { + GamePackageTeardownInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GamePackageTeardownInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GamePackageTeardownDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownDeleteCallbackId { + GamePackageTeardownDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GamePackageTeardownDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GamePackageTeardownTableHandle<'ctx> { + type InsertCallbackId = GamePackageTeardownInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownInsertCallbackId { + GamePackageTeardownInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GamePackageTeardownInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GamePackageTeardownTableHandle<'ctx> { + type DeleteCallbackId = GamePackageTeardownDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownDeleteCallbackId { + GamePackageTeardownDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GamePackageTeardownDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GamePackageTeardownUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GamePackageTeardownTableHandle<'ctx> { + type UpdateCallbackId = GamePackageTeardownUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownUpdateCallbackId { + GamePackageTeardownUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GamePackageTeardownUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GamePackageTeardownTableHandle<'ctx> { + type UpdateCallbackId = GamePackageTeardownUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GamePackageTeardownUpdateCallbackId { + GamePackageTeardownUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GamePackageTeardownUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `package_name` unique index on the table `game_package_teardown`, +/// which allows point queries on the field of the same name +/// via the [`GamePackageTeardownPackageNameUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_package_teardown().package_name().find(...)`. +pub struct GamePackageTeardownPackageNameUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GamePackageTeardownTableHandle<'ctx> { + /// Get a handle on the `package_name` unique index on the table `game_package_teardown`. + pub fn package_name(&self) -> GamePackageTeardownPackageNameUnique<'ctx> { + GamePackageTeardownPackageNameUnique { + imp: self.imp.get_unique_constraint::("package_name"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GamePackageTeardownPackageNameUnique<'ctx> { + /// Find the subscribed row whose `package_name` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_package_teardown"); + _table.add_unique_constraint::("package_name", |row| &row.package_name); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PackageTeardown`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_package_teardownQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PackageTeardown`. + fn game_package_teardown(&self) -> __sdk::__query_builder::Table; +} + +impl game_package_teardownQueryTableAccess for __sdk::QueryTableAccessor { + fn game_package_teardown(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_package_teardown") + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index b22e04af9..3b78e216d 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -581,6 +581,7 @@ pub mod game_operator_table; pub mod game_package_account_table; pub mod game_package_config_table; pub mod game_package_import_table; +pub mod game_package_teardown_table; pub mod game_party_command_dispatch_lane_table; pub mod game_party_command_intent_table; pub mod game_party_command_issuer_table; @@ -913,6 +914,7 @@ pub mod out_of_combat_sight_condition_type; pub mod package_account_type; pub mod package_config_type; pub mod package_import_type; +pub mod package_teardown_type; pub mod parsed_client_command_type; pub mod party_command_dispatch_lane_type; pub mod party_command_fixture_apply_after_gate_change_reducer; @@ -1137,6 +1139,7 @@ pub mod taunt_lock_type; pub mod taxi_flight_schedule_type; pub mod taxi_passenger_spline_type; pub mod taxi_service_reply_type; +pub mod teardown_package_reducer; pub mod teleport_event_type; pub mod terrain_chunk_type; pub mod threat_entry_type; @@ -1754,6 +1757,7 @@ pub use game_operator_table::*; pub use game_package_account_table::*; pub use game_package_config_table::*; pub use game_package_import_table::*; +pub use game_package_teardown_table::*; pub use game_party_command_dispatch_lane_table::*; pub use game_party_command_intent_table::*; pub use game_party_command_issuer_table::*; @@ -2086,6 +2090,7 @@ pub use out_of_combat_sight_condition_type::OutOfCombatSightCondition; pub use package_account_type::PackageAccount; pub use package_config_type::PackageConfig; pub use package_import_type::PackageImport; +pub use package_teardown_type::PackageTeardown; pub use parsed_client_command_type::ParsedClientCommand; pub use party_command_dispatch_lane_type::PartyCommandDispatchLane; pub use party_command_fixture_apply_after_gate_change_reducer::party_command_fixture_apply_after_gate_change; @@ -2310,6 +2315,7 @@ pub use taunt_lock_type::TauntLock; pub use taxi_flight_schedule_type::TaxiFlightSchedule; pub use taxi_passenger_spline_type::TaxiPassengerSpline; pub use taxi_service_reply_type::TaxiServiceReply; +pub use teardown_package_reducer::teardown_package; pub use teleport_event_type::TeleportEvent; pub use terrain_chunk_type::TerrainChunk; pub use threat_entry_type::ThreatEntry; @@ -4355,6 +4361,9 @@ pub enum Reducer { group_kind: u8, raid_slots: Vec, }, + TeardownPackage { + package_name: String, + }, TickAuras { schedule: AuraSchedule, }, @@ -4886,6 +4895,7 @@ impl __sdk::Reducer for Reducer { Reducer::StageVmapGeneration { .. } => "stage_vmap_generation", Reducer::StampImportMeta { .. } => "stamp_import_meta", Reducer::SyncGroupMirror { .. } => "sync_group_mirror", + Reducer::TeardownPackage { .. } => "teardown_package", Reducer::TickAuras { .. } => "tick_auras", Reducer::TickBreath { .. } => "tick_breath", Reducer::TickCreatures { .. } => "tick_creatures", @@ -8495,6 +8505,11 @@ Reducer::ResumeRelayArrival{ roster_revision: roster_revision.clone(), group_kind: group_kind.clone(), raid_slots: raid_slots.clone(), +}), + Reducer::TeardownPackage{ + package_name, +} => __sats::bsatn::to_vec(&teardown_package_reducer::TeardownPackageArgs { + package_name: package_name.clone(), }), Reducer::TickAuras{ schedule, @@ -8745,6 +8760,7 @@ pub struct DbUpdate { game_package_account: __sdk::TableUpdate, game_package_config: __sdk::TableUpdate, game_package_import: __sdk::TableUpdate, + game_package_teardown: __sdk::TableUpdate, game_party_command_dispatch_lane: __sdk::TableUpdate, game_party_command_intent: __sdk::TableUpdate, game_party_command_issuer: __sdk::TableUpdate, @@ -9490,6 +9506,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_package_import" => db_update .game_package_import .append(game_package_import_table::parse_table_update(table_update)?), + "game_package_teardown" => db_update.game_package_teardown.append( + game_package_teardown_table::parse_table_update(table_update)?, + ), "game_party_command_dispatch_lane" => { db_update.game_party_command_dispatch_lane.append( game_party_command_dispatch_lane_table::parse_table_update(table_update)?, @@ -10689,6 +10708,12 @@ impl __sdk::DbUpdate for DbUpdate { diff.game_package_import = cache .apply_diff_to_table::("game_package_import", &self.game_package_import) .with_updates_by_pk(|row| &row.id); + diff.game_package_teardown = cache + .apply_diff_to_table::( + "game_package_teardown", + &self.game_package_teardown, + ) + .with_updates_by_pk(|row| &row.package_name); diff.game_party_command_dispatch_lane = cache .apply_diff_to_table::( "game_party_command_dispatch_lane", @@ -11686,6 +11711,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_package_import" => db_update .game_package_import .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_package_teardown" => db_update + .game_package_teardown + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_party_command_dispatch_lane" => db_update .game_party_command_dispatch_lane .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -12560,6 +12588,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_package_import" => db_update .game_package_import .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_package_teardown" => db_update + .game_package_teardown + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_party_command_dispatch_lane" => db_update .game_party_command_dispatch_lane .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -13051,6 +13082,7 @@ pub struct AppliedDiff<'r> { game_package_account: __sdk::TableAppliedDiff<'r, PackageAccount>, game_package_config: __sdk::TableAppliedDiff<'r, PackageConfig>, game_package_import: __sdk::TableAppliedDiff<'r, PackageImport>, + game_package_teardown: __sdk::TableAppliedDiff<'r, PackageTeardown>, game_party_command_dispatch_lane: __sdk::TableAppliedDiff<'r, PartyCommandDispatchLane>, game_party_command_intent: __sdk::TableAppliedDiff<'r, PartyCommandIntent>, game_party_command_issuer: __sdk::TableAppliedDiff<'r, PartyCommandIssuer>, @@ -14083,6 +14115,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_package_import, event, ); + callbacks.invoke_table_row_callbacks::( + "game_package_teardown", + &self.game_package_teardown, + event, + ); callbacks.invoke_table_row_callbacks::( "game_party_command_dispatch_lane", &self.game_party_command_dispatch_lane, @@ -15382,6 +15419,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_package_account_table::register_table(client_cache); game_package_config_table::register_table(client_cache); game_package_import_table::register_table(client_cache); + game_package_teardown_table::register_table(client_cache); game_party_command_dispatch_lane_table::register_table(client_cache); game_party_command_intent_table::register_table(client_cache); game_party_command_issuer_table::register_table(client_cache); @@ -15671,6 +15709,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_package_account", "game_package_config", "game_package_import", + "game_package_teardown", "game_party_command_dispatch_lane", "game_party_command_intent", "game_party_command_issuer", diff --git a/gateway/src/stdb/bindings/package_teardown_type.rs b/gateway/src/stdb/bindings/package_teardown_type.rs new file mode 100644 index 000000000..7a8d4e481 --- /dev/null +++ b/gateway/src/stdb/bindings/package_teardown_type.rs @@ -0,0 +1,49 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PackageTeardown { + pub package_name: String, +} + +impl __sdk::InModule for PackageTeardown { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PackageTeardown`. +/// +/// Provides typed access to columns for query building. +pub struct PackageTeardownCols { + pub package_name: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PackageTeardown { + type Cols = PackageTeardownCols; + fn cols(table_name: &'static str) -> Self::Cols { + PackageTeardownCols { + package_name: __sdk::__query_builder::Col::new(table_name, "package_name"), + } + } +} + +/// Indexed column accessor struct for the table `PackageTeardown`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PackageTeardownIxCols { + pub package_name: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PackageTeardown { + type IxCols = PackageTeardownIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PackageTeardownIxCols { + package_name: __sdk::__query_builder::IxCol::new(table_name, "package_name"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PackageTeardown {} diff --git a/gateway/src/stdb/bindings/teardown_package_reducer.rs b/gateway/src/stdb/bindings/teardown_package_reducer.rs new file mode 100644 index 000000000..e979cc591 --- /dev/null +++ b/gateway/src/stdb/bindings/teardown_package_reducer.rs @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct TeardownPackageArgs { + pub package_name: String, +} + +impl From for super::Reducer { + fn from(args: TeardownPackageArgs) -> Self { + Self::TeardownPackage { + package_name: args.package_name, + } + } +} + +impl __sdk::InModule for TeardownPackageArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `teardown_package`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait teardown_package { + /// Request that the remote module invoke the reducer `teardown_package` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`teardown_package:teardown_package_then`] to run a callback after the reducer completes. + fn teardown_package(&self, package_name: String) -> __sdk::Result<()> { + self.teardown_package_then(package_name, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `teardown_package` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn teardown_package_then( + &self, + package_name: String, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl teardown_package for super::RemoteReducers { + fn teardown_package_then( + &self, + package_name: String, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(TeardownPackageArgs { package_name }, callback) + } +} From bf6887d2eeb2f3fb46af41dd2478e3ea47ddf280 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Wed, 7 Oct 2026 19:36:34 +0200 Subject: [PATCH 3/3] fix(build): refuse a Package table that sets its own name Package Teardown empties a table by its accessor. A table with its own name would keep its rows and stop the next publish. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/package-api.md | 3 ++- module/build.rs | 19 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/package-api.md b/docs/package-api.md index a8389ac5c..745a1b1b5 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -275,7 +275,8 @@ file without the root. A Package declares its own tables with `#[table(accessor = pkg__, ..)]`, the naming rule `docs/schema.md` states. The Package name in the accessor is what keeps two Packages from -colliding. +colliding. The accessor is also the table name Package Teardown empties, so the build refuses a +Package table that sets its own `name`. Core table accessors are named `game_*` and are reached at the crate root: `use crate::{game_world_entity, game_character};`. Row types are re-exported at the crate root under diff --git a/module/build.rs b/module/build.rs index 5bb9c50c7..7c3b64eeb 100644 --- a/module/build.rs +++ b/module/build.rs @@ -761,8 +761,19 @@ fn table_accessors(content: &str) -> Vec { } fn accessor_item(item: &str) -> Option { + let item = item.trim(); + // Teardown clears a table by name, and `name = ..` would make the name differ from the + // accessor this scan reads. + if item + .strip_prefix("name") + .is_some_and(|rest| rest.trim_start().starts_with('=')) + { + panic!( + "build.rs: a Package table sets `name`; keep the accessor as its table name so \ + Package Teardown can empty it" + ); + } let value = item - .trim() .strip_prefix("accessor")? .trim_start() .strip_prefix('=')? @@ -2072,6 +2083,12 @@ mod package_api_lint_tests { assert_eq!(table_accessors(source), ["pkg_demo_one", "pkg_demo_two"]); } + #[test] + #[should_panic(expected = "sets `name`")] + fn a_package_table_with_its_own_name_fails_the_build() { + table_accessors("#[table(accessor = pkg_demo_one, name = other)]\nstruct One;\n"); + } + #[test] fn a_package_registration_stops_after_teardown_and_core_code_never_does() { assert_eq!(