diff --git a/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md index c9f2e7343fd..50088d9a3e1 100644 --- a/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md +++ b/codex-plugin/plugins/spacetimedb/skills/typescript-server/SKILL.md @@ -72,7 +72,7 @@ const entity = table( ); ``` -Options: `name` (snake_case, recommended), `public: true`, `event: true`, `scheduled: (): any => reducerRef`, `indexes: [...]` +Options: `name` (snake_case, recommended), `public: true`, `event: true`, `indexes: [...]` `ctx.db` accessors are the keys passed to `schema({...})`, verbatim: `schema({ score_record })` → `ctx.db.score_record`. Use snake_case keys matching the table `name`. Client codegen converts case; server `ctx.db` does not. @@ -238,17 +238,21 @@ import { ScheduleAt } from 'spacetimedb'; // ScheduleAt comes from the root pa const tick_timer = table({ name: 'tick_timer', - scheduled: (): any => tick, // (): any => breaks circular dep }, { scheduled_id: t.u64().primaryKey().autoInc(), scheduled_at: t.scheduleAt(), }); export const tick = spacetimedb.reducer( + { onSchedule: tick_timer }, { timer: tick_timer.rowType }, (ctx, { timer }) => { /* timer row auto-deleted after this runs */ } ); +// `onSchedule` also works for scheduled procedures whose return type is `t.unit()`. +// Legacy table-side scheduling, `scheduled: (): any => tick`, still works but is not +// recommended for new code because it forces a forward reference. + // One-time: ScheduleAt.time(ctx.timestamp.microsSinceUnixEpoch + delayMicros) // Repeating: ScheduleAt.interval(60_000_000n) // Read time back from a scheduleAt value (tagged union): @@ -354,7 +358,7 @@ TypeScript outbound HTTP uses `ctx.http.fetch(url, options)`, including for non- Procedures and handlers open short database transactions with `ctx.withTx(tx => ...)`. Perform network I/O before opening the transaction; only database work belongs inside its callback. -Scheduled procedures use the ordinary scheduled-table shape. Its `scheduled` option references an exported `spacetimedb.procedure(...)` value instead of a reducer, and the procedure accepts the scheduled row as its argument. +Scheduled procedures use the same `onSchedule` binding as scheduled reducers. The procedure must be exported, return `t.unit()`, and accept the scheduled row as its argument. Inbound HTTP uses `httpHandler`, `httpRouter`, `Router`, and `SyncResponse`: diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 8fd148e4315..eb29f6369a0 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -203,7 +203,7 @@ pub fn cli() -> clap::Command { Arg::new("native-aot") .long("native-aot") .action(clap::ArgAction::SetTrue) - .help("Configure C# project for NativeAOT-LLVM compilation (experimental, Windows only)"), + .help("Configure C# project for NativeAOT-LLVM compilation (experimental; supported on Windows, and on Linux with .NET 10)"), ) .arg( common_args::dotnet_version().help("Target .NET SDK major version for C# projects (e.g. 8 or 10). Defaults to 10 except on macOS or when only .NET 8 is installed."), diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 745664880f0..7e70c1f71ec 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -316,7 +316,7 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), Arg::new("native_aot") .long("native-aot") .action(SetTrue) - .help("Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only)") + .help("Use NativeAOT-LLVM compilation for C# modules (experimental; supported on Windows, and on Linux with .NET 10)") ) .arg(common_args::dotnet_version()) .after_help("Run `spacetime help publish` for more detailed information.") diff --git a/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md b/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md index 4a29ff7465f..d529b6ea2c4 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md +++ b/docs/docs/00200-core-concepts/00100-databases/00500-migrations/00200-automatic-migrations.md @@ -32,6 +32,8 @@ These changes are allowed by automatic migration, but may cause runtime errors f - **Adding new columns to the end of a table with a default value.** The new column must be added at the end of the table definition and must have a default value specified. Non-updated clients will not be aware of the new column. - **Changing or removing reducers.** Clients attempting to call the old version of a changed reducer or a removed reducer will receive runtime errors. - **Changing tables from public to private.** Clients subscribed to a newly-private table will receive runtime errors. +- **Changing table or column accessor names while preserving canonical names.** The stored data can be migrated, but source code that refers to the old accessors must be updated. This may also change generated index names shown in SQL or migration output, even when an index's accessor and canonical name are unchanged. +- **Removing empty tables.** SpacetimeDB can remove a table only if it has no rows. Removing a table disconnects active clients. Clients using bindings or subscription queries generated from the old schema must be updated before reconnecting, because the removed table no longer exists. - **Removing `Primary Key` annotations.** Non-updated clients will still use the old primary key as a unique key in their local cache, which can result in non-deterministic behavior when updates are received. - **Removing indexes.** This is only breaking in specific situations. The main issue occurs with subscription queries involving semijoins, such as: @@ -48,12 +50,13 @@ These changes are allowed by automatic migration, but may cause runtime errors f The following changes cannot be performed with automatic migration and will cause the publish to fail: -- **Removing tables.** -- **Removing or modifying existing columns.** This includes changing the type, renaming, or reordering columns. +- **Removing non-empty tables.** Empty tables can be removed automatically, but table removal fails if the existing table contains rows. +- **Removing or modifying existing columns.** This includes changing the type, canonical name, or order of columns. Changing only the generated accessor alias is allowed, but source code that refers to the old accessor must be updated. - **Adding columns without a default value.** New columns must have a default value so existing rows can be populated. - **Adding columns in the middle of a table.** New columns must be added at the end of the table definition. - **Changing whether a table is used for `scheduling`.** - **Adding `Unique` or `Primary Key` constraints.** This could result in existing tables being in an invalid state. +- **Changing an index accessor name.** Create a new index accessor instead of renaming an existing one. ## Working with Forbidden Changes @@ -91,6 +94,7 @@ For complex schema changes that aren't supported by automatic migration: During automatic migrations, active client connections are maintained and subscriptions continue to function. However: - Clients may witness brief interruptions in scheduled reducers (such as game loops) +- Some migrations, such as removing a table, disconnect active clients so they reconnect against the new schema - New module versions may remove or change reducers, causing runtime errors for clients calling those reducers - Clients won't automatically know about schema changes - you may need to regenerate and update client bindings diff --git a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md index 7e8a28ce52d..237e968cc54 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md +++ b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00300-reducers.md @@ -558,7 +558,7 @@ import { ScheduleAt } from 'spacetimedb'; import { schema, t, table } from 'spacetimedb/server'; // Define a schedule table for the procedure -const fetchSchedule = table( +const fetch_schedule = table( { name: 'fetch_schedule' }, { scheduledId: t.u64().primaryKey().autoInc(), @@ -567,14 +567,14 @@ const fetchSchedule = table( } ); -const spacetimedb = schema({ fetchSchedule }); +const spacetimedb = schema({ fetch_schedule }); export default spacetimedb; // The procedure to be scheduled, bound to the schedule table with `onSchedule`. // A scheduled procedure must return `t.unit()`. export const fetchExternalData = spacetimedb.procedure( - { onSchedule: fetchSchedule }, - { arg: fetchSchedule.rowType }, + { onSchedule: fetch_schedule }, + { arg: fetch_schedule.rowType }, t.unit(), (ctx, { arg }) => { const response = ctx.http.fetch(arg.url); @@ -585,7 +585,7 @@ export const fetchExternalData = spacetimedb.procedure( // From a reducer, schedule the procedure by inserting into the schedule table export const queueFetch = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) => { - ctx.db.fetchSchedule.insert({ + ctx.db.fetch_schedule.insert({ scheduledId: 0n, scheduledAt: ScheduleAt.interval(0n), // Run immediately url, diff --git a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00400-reducer-context.md b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00400-reducer-context.md index f05e0db2d0e..b448e034de6 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00400-reducer-context.md +++ b/docs/docs/00200-core-concepts/00200-functions/00300-reducers/00400-reducer-context.md @@ -257,7 +257,7 @@ SPACETIMEDB_REDUCER(update_score, ReducerContext ctx, uint32_t new_score) { The connection ID identifies the specific client connection that invoked the reducer. This is useful for tracking sessions or implementing per-connection state. :::note -The connection ID may be absent for reducers invoked by the system (such as scheduled reducers or lifecycle reducers) or when called via the CLI without specifying a connection. In TypeScript modules, `ctx.connectionId` is `ConnectionId | null`. +The connection ID may be absent for reducers invoked without a client connection, such as `init`, scheduled reducers, or CLI calls without an explicit connection. Client-connected and client-disconnected reducers receive the connection ID for the connection being opened or closed. In TypeScript modules, `ctx.connectionId` is still typed as `ConnectionId | null`, so shared helper code should handle the nullable type. ::: ### Timestamp @@ -324,7 +324,7 @@ Scheduled reducers and procedures are private by default in SpacetimeDB 2.x, so ```typescript import { schema, table, t } from 'spacetimedb/server'; -const scheduledTask = table( +const scheduled_task = table( { name: 'scheduled_task' }, { taskId: t.u64().primaryKey().autoInc(), @@ -333,12 +333,12 @@ const scheduledTask = table( } ); -const spacetimedb = schema({ scheduledTask }); +const spacetimedb = schema({ scheduled_task }); export default spacetimedb; export const sendReminder = spacetimedb.reducer( - { onSchedule: scheduledTask }, - { arg: scheduledTask.rowType }, + { onSchedule: scheduled_task }, + { arg: scheduled_task.rowType }, (_ctx, { arg }) => { console.log(`Reminder: ${arg.message}`); } diff --git a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md index 615db33c756..8ac79f72e00 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md +++ b/docs/docs/00200-core-concepts/00200-functions/00400-procedures.md @@ -31,14 +31,15 @@ export const add_two_numbers = spacetimedb.procedure( ``` The `spacetimedb.procedure` function takes: -* the procedure name, -* (optional) an object representing its parameter types, +* optional procedure options, such as `onSchedule`, +* an optional object representing its parameter types, * its return type, * and the procedure function itself. -The function will receive a `ProcedureContext` and an object of its arguments, and it must return -a value corresponding to its return type. This return value will be sent to the caller, but will -not be broadcast to any other clients. +The exported value's name becomes the procedure name. The callback receives a `ProcedureContext` +and, when the procedure has parameters, an object of its arguments. It must return a value +corresponding to its return type. This return value will be sent to the caller, but will not be +broadcast to any other clients. @@ -128,7 +129,7 @@ This means there's no `ctx.db` field to access the database. Instead, procedure code must manage transactions explicitly with `ProcedureCtx.withTx`. ```typescript -const myTable = table( +const my_table = table( { name: "my_table" }, { a: t.u32(), @@ -136,12 +137,12 @@ const myTable = table( }, ) -const spacetimedb = schema({ myTable }); +const spacetimedb = schema({ my_table }); export default spacetimedb; export const insertAValue = spacetimedb.procedure({ a: t.u32(), b: t.u32() }, t.unit(), (ctx, { a, b }) => { ctx.withTx(ctx => { - ctx.db.myTable.insert({ a, b }); + ctx.db.my_table.insert({ a, b }); }); return {}; }) @@ -235,8 +236,8 @@ struct MyTable { #[spacetimedb::procedure] fn insert_a_value(ctx: &mut ProcedureContext, a: u32, b: String) { - ctx.with_tx(|ctx| { - ctx.my_table().insert(MyTable { a, b }); + ctx.with_tx(|tx| { + tx.db.my_table().insert(MyTable { a, b }); }); } ``` @@ -328,7 +329,7 @@ export const maybeInsertAValue = spacetimedb.procedure({ a: t.u32(), b: t.string if (a < 10) { throw new SenderError("a is less than 10!"); } - ctx.db.myTable.insert({ a, b }); + ctx.db.my_table.insert({ a, b }); }); }) ``` @@ -368,11 +369,11 @@ For fallible database operations, instead use `ProcedureContext::try_with_tx`: ```rust #[spacetimedb::procedure] fn maybe_insert_a_value(ctx: &mut ProcedureContext, a: u32, b: String) { - ctx.try_with_tx(|ctx| { + ctx.try_with_tx(|tx| { if a < 10 { return Err("a is less than 10!"); } - ctx.my_table().insert(MyTable { a, b }); + tx.db.my_table().insert(MyTable { a, b }); Ok(()) }); } @@ -1190,7 +1191,7 @@ A common use case for procedures is integrating with external APIs like OpenAI's import { schema, t, table, SenderError } from 'spacetimedb/server'; import { TimeDuration } from 'spacetimedb'; -const aiMessage = table( +const ai_message = table( { name: 'ai_message', public: true }, { user: t.identity(), @@ -1200,7 +1201,7 @@ const aiMessage = table( } ); -const spacetimedb = schema({ aiMessage }); +const spacetimedb = schema({ ai_message }); export default spacetimedb; export const askAi = spacetimedb.procedure( @@ -1235,7 +1236,7 @@ export const askAi = spacetimedb.procedure( // Store the conversation in the database ctx.withTx(txCtx => { - txCtx.db.aiMessage.insert({ + txCtx.db.ai_message.insert({ user: txCtx.sender, prompt, response: aiResponse, diff --git a/docs/docs/00200-core-concepts/00200-functions/00500-views.md b/docs/docs/00200-core-concepts/00200-functions/00500-views.md index 7e97e0078e3..d7cd1c99d98 100644 --- a/docs/docs/00200-core-concepts/00200-functions/00500-views.md +++ b/docs/docs/00200-core-concepts/00200-functions/00500-views.md @@ -40,7 +40,7 @@ const players = table( } ); -const playerLevels = table( +const player_levels = table( { name: 'player_levels', public: true }, { playerId: t.u64().unique(), @@ -48,7 +48,7 @@ const playerLevels = table( } ); -const spacetimedb = schema({ players, playerLevels }); +const spacetimedb = schema({ players, player_levels }); export default spacetimedb; // At-most-one row: return Option via t.option(...) @@ -75,7 +75,7 @@ export const playersForLevel = spacetimedb.anonymousView( t.array(playerAndLevelRow), (ctx) => { const out: Array<{ id: bigint; name: string; level: bigint }> = []; - for (const playerLevel of ctx.db.playerLevels.level.filter(2n)) { + for (const playerLevel of ctx.db.player_levels.level.filter(2n)) { const p = ctx.db.players.id.find(playerLevel.playerId); if (p) out.push({ id: p.id, name: p.name, level: playerLevel.level }); } @@ -493,7 +493,7 @@ const entity = table( ); // Track which chunks each player is subscribed to -const playerChunk = table( +const player_chunk = table( { name: 'player_chunk', public: true }, { playerId: t.u64().primaryKey(), @@ -521,7 +521,7 @@ export const entitiesInMyChunk = spacetimedb.view( const player = ctx.db.players.identity.find(ctx.sender); if (!player) return []; - const chunk = ctx.db.playerChunk.playerId.find(player.id); + const chunk = ctx.db.player_chunk.playerId.find(player.id); if (!chunk) return []; return Array.from(ctx.db.entity.chunkX.filter(chunk.chunkX)) @@ -1067,7 +1067,7 @@ const players = table( } ); -const playerLevels = table( +const player_levels = table( { name: 'player_levels', public: true }, { playerId: t.u64().unique(), @@ -1075,7 +1075,7 @@ const playerLevels = table( } ); -const spacetimedb = schema({ players, playerLevels }); +const spacetimedb = schema({ players, player_levels }); export default spacetimedb; export const allPlayers = spacetimedb.anonymousView( diff --git a/docs/docs/00200-core-concepts/00300-tables/00210-file-storage.md b/docs/docs/00200-core-concepts/00300-tables/00210-file-storage.md index 0e29291d1fe..4512da9c82c 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00210-file-storage.md +++ b/docs/docs/00200-core-concepts/00300-tables/00210-file-storage.md @@ -20,7 +20,7 @@ Store binary data using `Vec` (Rust), `List` (C#), `std::vector { // Delete existing avatar if present - ctx.db.userAvatar.userId.delete(userId); + ctx.db.user_avatar.userId.delete(userId); // Insert new avatar - ctx.db.userAvatar.insert({ + ctx.db.user_avatar.insert({ userId, mimeType, data, diff --git a/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md b/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md index 50bcc4abc22..1113b639b53 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md +++ b/docs/docs/00200-core-concepts/00300-tables/00400-access-permissions.md @@ -568,7 +568,7 @@ Use views to return a custom type that omits sensitive columns. The view reads f import {schema, t, table} from 'spacetimedb/server'; // Private table with sensitive data -const userAccount = table( +const user_account = table( { name: 'user_account' }, // Private by default { id: t.u64().primaryKey().autoInc(), @@ -581,7 +581,7 @@ const userAccount = table( } ); -const spacetimedb = schema({ userAccount }); +const spacetimedb = schema({ user_account }); export default spacetimedb; // Public type without sensitive columns @@ -597,7 +597,7 @@ export const myProfile = spacetimedb.view( t.option(publicUserProfile), (ctx) => { // Look up the caller's account by their identity (unique index) - const user = ctx.db.userAccount.identity.find(ctx.sender); + const user = ctx.db.user_account.identity.find(ctx.sender); if (!user) return null; return { id: user.id, diff --git a/docs/docs/00200-core-concepts/00300-tables/00500-schedule-tables.md b/docs/docs/00200-core-concepts/00300-tables/00500-schedule-tables.md index 0c3f5df4518..e25abf89146 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00500-schedule-tables.md +++ b/docs/docs/00200-core-concepts/00300-tables/00500-schedule-tables.md @@ -176,6 +176,8 @@ To schedule an action, insert a row into the schedule table with a `scheduled_at - **At intervals** - Execute repeatedly at fixed time intervals (e.g., every 5 seconds) - **At specific times** - Execute once at an absolute timestamp +Interval schedules are anchored to their intended execution times. If the database is busy or offline long enough to miss one or more interval ticks, SpacetimeDB schedules the next future tick rather than running missed ticks back-to-back or drifting the schedule from the delayed execution time. + ### Scheduling at Intervals Use intervals for periodic tasks like game ticks, heartbeats, or recurring maintenance: @@ -405,6 +407,8 @@ ctx.db[reminder].insert(Reminder{ 3. **When the time arrives**, the specified reducer/procedure is automatically called with the row as a parameter 4. **The row is typically deleted** or updated by the reducer after processing +For interval schedules, the next run is calculated from the previous intended run time. Missed interval ticks are skipped, so a delayed scheduled reducer or procedure resumes on the next future interval boundary. + ### Row Lifecycle SpacetimeDB passes the schedule row to the scheduled reducer or procedure as an argument. One-shot schedule rows are removed at different times depending on the kind of function being called: diff --git a/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md b/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md index e109d892ef4..7039a757d61 100644 --- a/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md +++ b/docs/docs/00200-core-concepts/00300-tables/00550-event-tables.md @@ -21,7 +21,7 @@ To declare a table as an event table, add the `event` attribute to the table def ```typescript -const damageEvent = table({ +const damage_event = table({ name: 'damage_event', public: true, event: true, @@ -32,7 +32,7 @@ const damageEvent = table({ }); const spacetimedb = schema({ - damageEvent, + damage_event, }); export default spacetimedb; ``` @@ -98,7 +98,7 @@ export const attack = spacetimedb.reducer( // Game logic... // Publish the event - ctx.db.damageEvent.insert({ + ctx.db.damage_event.insert({ entityId: targetId, damage, source: "melee_attack", diff --git a/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md b/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md index f107ea77513..a022cc1dc65 100644 --- a/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md +++ b/docs/docs/00200-core-concepts/00500-authentication/00500-usage.md @@ -217,8 +217,12 @@ As an example, let's say that your tokens have a "roles" claim, which is a list ```typescript +import { SenderError, type InferSchema, type ReducerCtx } from 'spacetimedb/server'; + +type Ctx = ReducerCtx>; + // Return an error to the client if they don't have admin rights. -function ensureAdminAccess(ctx: ReducerCtx) { +function ensureAdminAccess(ctx: Ctx) { const auth = ctx.senderAuth; if (auth.isInternal) { return; diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 6a065748f69..1a8e9295db0 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -132,7 +132,7 @@ Run `spacetime help publish` for more detailed information. * `--no-config` — Ignore spacetime.json configuration * `--env ` — Environment name for config file layering (e.g., dev, staging) -* `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only) +* `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental; supported on Windows, and on Linux with .NET 10) * `--dotnet-version ` — Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted. @@ -466,7 +466,7 @@ Initializes a new spacetime project. * `-t`, `--template