迁移后端到stdb
This commit is contained in:
663
.cursor/rules/spacetimedb-rust.mdc
Normal file
663
.cursor/rules/spacetimedb-rust.mdc
Normal file
@@ -0,0 +1,663 @@
|
||||
---
|
||||
description: "⛔ MANDATORY: Read this ENTIRE file before writing ANY SpacetimeDB Rust code. Contains SDK patterns from official documentation."
|
||||
globs: **/*.rs
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# SpacetimeDB Rust SDK
|
||||
|
||||
## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS
|
||||
|
||||
These are **actual errors** observed when LLMs generate SpacetimeDB Rust code:
|
||||
|
||||
### 1. Wrong Crate for Server vs Client
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using client crate for server module
|
||||
use spacetimedb_sdk::*; // This is for CLIENTS only!
|
||||
|
||||
// ✅ CORRECT — use spacetimedb for server modules
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
```
|
||||
|
||||
### 2. Wrong Table Macro Syntax
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using attribute-style like C#
|
||||
#[spacetimedb::table]
|
||||
#[primary_key]
|
||||
pub struct User { ... }
|
||||
|
||||
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
|
||||
#[derive(SpacetimeType)]
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
|
||||
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Wrong Table Access Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
|
||||
ctx.Db.user.Insert(...);
|
||||
ctx.db().user().insert(...);
|
||||
ctx.db.player; // Field access
|
||||
|
||||
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
|
||||
ctx.db.user().insert(User { ... });
|
||||
ctx.db.user().identity().find(ctx.sender);
|
||||
ctx.db.player().id().find(&player_id);
|
||||
```
|
||||
|
||||
### 4. Wrong Update Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — partial update or using .update() directly on table
|
||||
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
|
||||
|
||||
// ✅ CORRECT — find existing, spread it, update via primary key accessor
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Wrong Reducer Return Type
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returning data from reducer
|
||||
#[reducer]
|
||||
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
|
||||
|
||||
// ❌ WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
|
||||
|
||||
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
|
||||
#[reducer]
|
||||
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("Value cannot be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wrong Client Connection Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — subscribing before connected
|
||||
let conn = DbConnection::builder().build()?;
|
||||
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
|
||||
|
||||
// ✅ CORRECT — subscribe in on_connect callback
|
||||
DbConnection::builder()
|
||||
.on_connect(|conn, identity, token| {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
})
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### 7. Forgetting to Advance the Connection
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — connection never processes messages
|
||||
let conn = DbConnection::builder().build()?;
|
||||
// ... callbacks never fire ...
|
||||
|
||||
// ✅ CORRECT — must call one of these to process messages
|
||||
conn.run_threaded(); // Spawn background thread
|
||||
// OR
|
||||
conn.run_async().await; // Async task
|
||||
// OR (in game loop)
|
||||
conn.frame_tick()?; // Manual polling
|
||||
```
|
||||
|
||||
### 8. Missing Table Trait Import
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — "no method named `insert` found"
|
||||
use spacetimedb::{table, reducer, ReducerContext};
|
||||
ctx.db.user().insert(...); // ERROR!
|
||||
|
||||
// ✅ CORRECT — import Table trait for table methods
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext};
|
||||
ctx.db.user().insert(...); // Works!
|
||||
```
|
||||
|
||||
### 9. Wrong ScheduleAt Variant
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — At variant doesn't exist
|
||||
scheduled_at: ScheduleAt::At(future_time),
|
||||
|
||||
// ✅ CORRECT — use Time variant
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
```
|
||||
|
||||
### 10. Identity to String Conversion
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — to_hex() returns HexString<32>, not String
|
||||
let id: String = identity.to_hex(); // Type mismatch!
|
||||
|
||||
// ✅ CORRECT — chain .to_string()
|
||||
let id: String = identity.to_hex().to_string();
|
||||
```
|
||||
|
||||
### 11. Timestamp Duration Extraction
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returns Result, not Duration directly
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
|
||||
|
||||
// ✅ CORRECT — unwrap the Result
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_micros();
|
||||
```
|
||||
|
||||
### 12. Borrow After Move
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — `tool` moved into struct, then borrowed
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if tool == "eraser" { ... } // ERROR: value moved!
|
||||
|
||||
// ✅ CORRECT — check before move, or use clone
|
||||
let is_eraser = tool == "eraser";
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if is_eraser { ... }
|
||||
```
|
||||
|
||||
### 13. Client SDK Uses Blocking I/O
|
||||
|
||||
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor.
|
||||
|
||||
### 14. Wrong Schedule Syntax
|
||||
```rust
|
||||
// ❌ WRONG — `schedule` is not a valid table type
|
||||
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
|
||||
|
||||
// ✅ CORRECT — `scheduled` is a valid table type
|
||||
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
|
||||
```
|
||||
---
|
||||
|
||||
## 1) Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros |
|
||||
| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" |
|
||||
| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index |
|
||||
| `&mut ReducerContext` | `&ReducerContext` | Wrong context type |
|
||||
| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" |
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## 2) Table Definition (CRITICAL)
|
||||
|
||||
**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!**
|
||||
|
||||
> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
|
||||
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
|
||||
#[derive(SpacetimeType)] // REMOVE THIS!
|
||||
#[table(accessor = task)]
|
||||
pub struct Task { ... }
|
||||
|
||||
// ✅ CORRECT — just the #[table] attribute
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
|
||||
#[unique]
|
||||
username: Option<String>,
|
||||
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
sender: Identity,
|
||||
text: String,
|
||||
sent: Timestamp,
|
||||
}
|
||||
|
||||
// With multi-column index
|
||||
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
|
||||
pub struct Task {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
pub owner_id: Identity,
|
||||
pub title: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Options
|
||||
|
||||
```rust
|
||||
#[table(accessor = my_table)] // Private table (default)
|
||||
#[table(accessor = my_table, public)] // Public table - clients can subscribe
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
|
||||
```rust
|
||||
#[primary_key] // Primary key (auto-indexed, enables .find())
|
||||
#[auto_inc] // Auto-increment (use with #[primary_key])
|
||||
#[unique] // Unique constraint (auto-indexed)
|
||||
#[index(btree)] // B-Tree index for queries
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```rust
|
||||
let row = ctx.db.task().insert(Task {
|
||||
id: 0, // auto-inc placeholder
|
||||
owner_id: ctx.sender,
|
||||
title: "New task".to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
let new_id = row.id; // Get the actual ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Reducers
|
||||
|
||||
### Definition Syntax
|
||||
|
||||
```rust
|
||||
use spacetimedb::{reducer, ReducerContext, Table};
|
||||
|
||||
#[reducer]
|
||||
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
|
||||
// Validate input
|
||||
if text.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Insert returns the inserted row
|
||||
let row = ctx.db.message().insert(Message {
|
||||
id: 0, // auto-inc placeholder
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
});
|
||||
|
||||
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Pattern (CRITICAL)
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
// Find existing row
|
||||
let user = ctx.db.user().identity().find(ctx.sender)
|
||||
.ok_or("User not found")?;
|
||||
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
ctx.db.user().identity().update(User {
|
||||
name: Some(name),
|
||||
..user // Preserves identity, online, etc.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
|
||||
```
|
||||
|
||||
### Delete Pattern
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
|
||||
ctx.db.message().id().delete(&message_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```rust
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
// Called when module is first published
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
// ctx.sender is the connecting identity
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: ctx.sender,
|
||||
username: None,
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ReducerContext fields
|
||||
|
||||
```rust
|
||||
ctx.sender // Identity of the caller
|
||||
ctx.timestamp // Current timestamp
|
||||
ctx.db // Database access
|
||||
ctx.rng // Deterministic RNG (use instead of rand)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Index Access
|
||||
|
||||
### Primary Key / Unique — `.find()` returns `Option<Row>`
|
||||
|
||||
```rust
|
||||
// Primary key lookup
|
||||
let user = ctx.db.user().identity().find(ctx.sender);
|
||||
|
||||
// Unique column lookup
|
||||
let user = ctx.db.user().username().find(&"alice".to_string());
|
||||
|
||||
if let Some(user) = user {
|
||||
// Found
|
||||
}
|
||||
```
|
||||
|
||||
### BTree Index — `.filter()` returns iterator
|
||||
|
||||
```rust
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
#[index(btree)]
|
||||
room_id: u64,
|
||||
|
||||
text: String,
|
||||
}
|
||||
|
||||
// Filter by indexed column
|
||||
for msg in ctx.db.message().room_id().filter(&room_id) {
|
||||
// Process each message in room
|
||||
}
|
||||
```
|
||||
|
||||
### No Index — `.iter()` + manual filter
|
||||
|
||||
```rust
|
||||
// Full table scan
|
||||
for user in ctx.db.user().iter() {
|
||||
if user.online {
|
||||
// Process online users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Custom Types
|
||||
|
||||
**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.**
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
// Custom struct for table fields
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// Custom enum
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub enum PlayerStatus {
|
||||
Idle,
|
||||
Walking(Position),
|
||||
Fighting(Identity),
|
||||
}
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: Identity,
|
||||
pub position: Position,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Scheduled Tables
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
|
||||
|
||||
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
|
||||
pub struct CleanupJob {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
|
||||
scheduled_at: ScheduleAt,
|
||||
target_id: u64,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
|
||||
// Job row is auto-deleted after reducer completes
|
||||
log::info!("Cleaning up: {}", job.target_id);
|
||||
}
|
||||
|
||||
// Schedule a job
|
||||
#[reducer]
|
||||
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
|
||||
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
|
||||
ctx.db.cleanup_job().insert(CleanupJob {
|
||||
scheduled_id: 0, // auto-inc placeholder
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
target_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel by deleting the row
|
||||
#[reducer]
|
||||
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
|
||||
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Client SDK
|
||||
|
||||
```rust
|
||||
// Connection pattern
|
||||
let conn = DbConnection::builder()
|
||||
.with_uri("http://localhost:3000")
|
||||
.with_module_name("my-module")
|
||||
.with_token(load_saved_token()) // None for first connection
|
||||
.on_connect(on_connected)
|
||||
.build()
|
||||
.expect("Failed to connect");
|
||||
|
||||
// Subscribe in on_connect callback, NOT before!
|
||||
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ CRITICAL: Advance the Connection
|
||||
|
||||
**You MUST call one of these** — without it, no callbacks fire:
|
||||
|
||||
```rust
|
||||
conn.run_threaded(); // Background thread (simplest)
|
||||
conn.run_async().await; // Async task
|
||||
conn.frame_tick()?; // Manual polling (game loops)
|
||||
```
|
||||
|
||||
### Table Access & Callbacks
|
||||
|
||||
```rust
|
||||
// Iterate
|
||||
for user in ctx.db.user().iter() { ... }
|
||||
|
||||
// Find by primary key
|
||||
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
|
||||
|
||||
// Row callbacks
|
||||
ctx.db.user().on_insert(|ctx, user| { ... });
|
||||
ctx.db.user().on_update(|ctx, old, new| { ... });
|
||||
ctx.db.user().on_delete(|ctx, user| { ... });
|
||||
|
||||
// Call reducers
|
||||
ctx.reducers.set_name("Alice".to_string()).unwrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Procedures (Beta)
|
||||
|
||||
**Procedures are for side effects (HTTP, filesystem) that reducers can't do.**
|
||||
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
// Simple procedure
|
||||
#[procedure]
|
||||
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
|
||||
a as u64 + b as u64
|
||||
}
|
||||
|
||||
// Procedure with database access
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
// HTTP request (allowed in procedures, not reducers)
|
||||
let data = fetch_from_url(&url)?;
|
||||
|
||||
// Database access requires explicit transaction
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData {
|
||||
id: 0,
|
||||
content: data,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
|
||||
---
|
||||
|
||||
## 9) Logging
|
||||
|
||||
```rust
|
||||
use spacetimedb::log;
|
||||
|
||||
log::trace!("Detailed trace");
|
||||
log::debug!("Debug info");
|
||||
log::info!("Information");
|
||||
log::warn!("Warning");
|
||||
log::error!("Error occurred");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Commands
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
|
||||
# View logs
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Hard Requirements
|
||||
|
||||
**Rust-specific:**
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc.
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk`
|
||||
6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
7. **Use `ctx.rng`** — not `rand` crate for random numbers
|
||||
8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers
|
||||
9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()`
|
||||
10. **Subscribe in `on_connect` callback** — not before connection is established
|
||||
11. **Update requires full row** — spread existing row with `..existing`
|
||||
12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()`
|
||||
14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes
|
||||
116
.cursor/rules/spacetimedb.mdc
Normal file
116
.cursor/rules/spacetimedb.mdc
Normal file
@@ -0,0 +1,116 @@
|
||||
---
|
||||
description: "⛔ MANDATORY: Core SpacetimeDB concepts (all languages)."
|
||||
globs: **/*.ts,**/*.tsx,**/*.js,**/*.jsx,**/*.rs,**/*.cs
|
||||
alwaysApply: true
|
||||
---
|
||||
# SpacetimeDB Rules (All Languages)
|
||||
|
||||
## Migrating from 1.0 to 2.0?
|
||||
|
||||
**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
|
||||
|
||||
---
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random
|
||||
3. **Read data via tables/subscriptions** — not reducer return values
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
|
||||
5. **`ctx.sender` is the authenticated principal** — never trust identity args
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Index System
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
You can add explicit indexes on non-unique columns for query performance.
|
||||
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Login to allow remote database deployment e.g. to maincloud
|
||||
spacetime login
|
||||
|
||||
# Start local SpacetimeDB
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
|
||||
# Clear and republish
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
|
||||
# Generate client bindings
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
|
||||
# View logs
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
- The database owner can view utilization and performance metrics on the dashboard
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
@@ -17,6 +17,11 @@ VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image"
|
||||
NODE_SERVER_ADDR=":8081"
|
||||
NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
||||
|
||||
# 前端直连 SpacetimeDB 所需配置。
|
||||
# 浏览器端当前直接订阅认证/存档/资料库相关 view,并调用对应 procedure。
|
||||
VITE_SPACETIME_URI="ws://127.0.0.1:3000"
|
||||
VITE_SPACETIME_DATABASE_NAME="xushi-p4wfr"
|
||||
|
||||
# Local Caddy upstream target used for dist-based testing.
|
||||
CADDY_API_UPSTREAM="http://127.0.0.1:8081"
|
||||
|
||||
|
||||
770
.github/copilot-instructions.md
vendored
Normal file
770
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,770 @@
|
||||
# SpacetimeDB Rules (All Languages)
|
||||
|
||||
## Migrating from 1.0 to 2.0?
|
||||
|
||||
**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
|
||||
|
||||
---
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random
|
||||
3. **Read data via tables/subscriptions** — not reducer return values
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
|
||||
5. **`ctx.sender` is the authenticated principal** — never trust identity args
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Index System
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
You can add explicit indexes on non-unique columns for query performance.
|
||||
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Login to allow remote database deployment e.g. to maincloud
|
||||
spacetime login
|
||||
|
||||
# Start local SpacetimeDB
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
|
||||
# Clear and republish
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
|
||||
# Generate client bindings
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
|
||||
# View logs
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
- The database owner can view utilization and performance metrics on the dashboard
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB Rust SDK
|
||||
|
||||
## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS
|
||||
|
||||
These are **actual errors** observed when LLMs generate SpacetimeDB Rust code:
|
||||
|
||||
### 1. Wrong Crate for Server vs Client
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using client crate for server module
|
||||
use spacetimedb_sdk::*; // This is for CLIENTS only!
|
||||
|
||||
// ✅ CORRECT — use spacetimedb for server modules
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
```
|
||||
|
||||
### 2. Wrong Table Macro Syntax
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using attribute-style like C#
|
||||
#[spacetimedb::table]
|
||||
#[primary_key]
|
||||
pub struct User { ... }
|
||||
|
||||
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
|
||||
#[derive(SpacetimeType)]
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
|
||||
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Wrong Table Access Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
|
||||
ctx.Db.user.Insert(...);
|
||||
ctx.db().user().insert(...);
|
||||
ctx.db.player; // Field access
|
||||
|
||||
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
|
||||
ctx.db.user().insert(User { ... });
|
||||
ctx.db.user().identity().find(ctx.sender);
|
||||
ctx.db.player().id().find(&player_id);
|
||||
```
|
||||
|
||||
### 4. Wrong Update Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — partial update or using .update() directly on table
|
||||
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
|
||||
|
||||
// ✅ CORRECT — find existing, spread it, update via primary key accessor
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Wrong Reducer Return Type
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returning data from reducer
|
||||
#[reducer]
|
||||
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
|
||||
|
||||
// ❌ WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
|
||||
|
||||
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
|
||||
#[reducer]
|
||||
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("Value cannot be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wrong Client Connection Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — subscribing before connected
|
||||
let conn = DbConnection::builder().build()?;
|
||||
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
|
||||
|
||||
// ✅ CORRECT — subscribe in on_connect callback
|
||||
DbConnection::builder()
|
||||
.on_connect(|conn, identity, token| {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
})
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### 7. Forgetting to Advance the Connection
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — connection never processes messages
|
||||
let conn = DbConnection::builder().build()?;
|
||||
// ... callbacks never fire ...
|
||||
|
||||
// ✅ CORRECT — must call one of these to process messages
|
||||
conn.run_threaded(); // Spawn background thread
|
||||
// OR
|
||||
conn.run_async().await; // Async task
|
||||
// OR (in game loop)
|
||||
conn.frame_tick()?; // Manual polling
|
||||
```
|
||||
|
||||
### 8. Missing Table Trait Import
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — "no method named `insert` found"
|
||||
use spacetimedb::{table, reducer, ReducerContext};
|
||||
ctx.db.user().insert(...); // ERROR!
|
||||
|
||||
// ✅ CORRECT — import Table trait for table methods
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext};
|
||||
ctx.db.user().insert(...); // Works!
|
||||
```
|
||||
|
||||
### 9. Wrong ScheduleAt Variant
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — At variant doesn't exist
|
||||
scheduled_at: ScheduleAt::At(future_time),
|
||||
|
||||
// ✅ CORRECT — use Time variant
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
```
|
||||
|
||||
### 10. Identity to String Conversion
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — to_hex() returns HexString<32>, not String
|
||||
let id: String = identity.to_hex(); // Type mismatch!
|
||||
|
||||
// ✅ CORRECT — chain .to_string()
|
||||
let id: String = identity.to_hex().to_string();
|
||||
```
|
||||
|
||||
### 11. Timestamp Duration Extraction
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returns Result, not Duration directly
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
|
||||
|
||||
// ✅ CORRECT — unwrap the Result
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_micros();
|
||||
```
|
||||
|
||||
### 12. Borrow After Move
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — `tool` moved into struct, then borrowed
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if tool == "eraser" { ... } // ERROR: value moved!
|
||||
|
||||
// ✅ CORRECT — check before move, or use clone
|
||||
let is_eraser = tool == "eraser";
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if is_eraser { ... }
|
||||
```
|
||||
|
||||
### 13. Client SDK Uses Blocking I/O
|
||||
|
||||
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor.
|
||||
|
||||
### 14. Wrong Schedule Syntax
|
||||
```rust
|
||||
// ❌ WRONG — `schedule` is not a valid table type
|
||||
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
|
||||
|
||||
// ✅ CORRECT — `scheduled` is a valid table type
|
||||
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
|
||||
```
|
||||
---
|
||||
|
||||
## 1) Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros |
|
||||
| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" |
|
||||
| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index |
|
||||
| `&mut ReducerContext` | `&ReducerContext` | Wrong context type |
|
||||
| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" |
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## 2) Table Definition (CRITICAL)
|
||||
|
||||
**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!**
|
||||
|
||||
> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
|
||||
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
|
||||
#[derive(SpacetimeType)] // REMOVE THIS!
|
||||
#[table(accessor = task)]
|
||||
pub struct Task { ... }
|
||||
|
||||
// ✅ CORRECT — just the #[table] attribute
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
|
||||
#[unique]
|
||||
username: Option<String>,
|
||||
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
sender: Identity,
|
||||
text: String,
|
||||
sent: Timestamp,
|
||||
}
|
||||
|
||||
// With multi-column index
|
||||
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
|
||||
pub struct Task {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
pub owner_id: Identity,
|
||||
pub title: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Options
|
||||
|
||||
```rust
|
||||
#[table(accessor = my_table)] // Private table (default)
|
||||
#[table(accessor = my_table, public)] // Public table - clients can subscribe
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
|
||||
```rust
|
||||
#[primary_key] // Primary key (auto-indexed, enables .find())
|
||||
#[auto_inc] // Auto-increment (use with #[primary_key])
|
||||
#[unique] // Unique constraint (auto-indexed)
|
||||
#[index(btree)] // B-Tree index for queries
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```rust
|
||||
let row = ctx.db.task().insert(Task {
|
||||
id: 0, // auto-inc placeholder
|
||||
owner_id: ctx.sender,
|
||||
title: "New task".to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
let new_id = row.id; // Get the actual ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Reducers
|
||||
|
||||
### Definition Syntax
|
||||
|
||||
```rust
|
||||
use spacetimedb::{reducer, ReducerContext, Table};
|
||||
|
||||
#[reducer]
|
||||
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
|
||||
// Validate input
|
||||
if text.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Insert returns the inserted row
|
||||
let row = ctx.db.message().insert(Message {
|
||||
id: 0, // auto-inc placeholder
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
});
|
||||
|
||||
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Pattern (CRITICAL)
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
// Find existing row
|
||||
let user = ctx.db.user().identity().find(ctx.sender)
|
||||
.ok_or("User not found")?;
|
||||
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
ctx.db.user().identity().update(User {
|
||||
name: Some(name),
|
||||
..user // Preserves identity, online, etc.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
|
||||
```
|
||||
|
||||
### Delete Pattern
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
|
||||
ctx.db.message().id().delete(&message_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```rust
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
// Called when module is first published
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
// ctx.sender is the connecting identity
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: ctx.sender,
|
||||
username: None,
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ReducerContext fields
|
||||
|
||||
```rust
|
||||
ctx.sender // Identity of the caller
|
||||
ctx.timestamp // Current timestamp
|
||||
ctx.db // Database access
|
||||
ctx.rng // Deterministic RNG (use instead of rand)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Index Access
|
||||
|
||||
### Primary Key / Unique — `.find()` returns `Option<Row>`
|
||||
|
||||
```rust
|
||||
// Primary key lookup
|
||||
let user = ctx.db.user().identity().find(ctx.sender);
|
||||
|
||||
// Unique column lookup
|
||||
let user = ctx.db.user().username().find(&"alice".to_string());
|
||||
|
||||
if let Some(user) = user {
|
||||
// Found
|
||||
}
|
||||
```
|
||||
|
||||
### BTree Index — `.filter()` returns iterator
|
||||
|
||||
```rust
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
#[index(btree)]
|
||||
room_id: u64,
|
||||
|
||||
text: String,
|
||||
}
|
||||
|
||||
// Filter by indexed column
|
||||
for msg in ctx.db.message().room_id().filter(&room_id) {
|
||||
// Process each message in room
|
||||
}
|
||||
```
|
||||
|
||||
### No Index — `.iter()` + manual filter
|
||||
|
||||
```rust
|
||||
// Full table scan
|
||||
for user in ctx.db.user().iter() {
|
||||
if user.online {
|
||||
// Process online users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Custom Types
|
||||
|
||||
**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.**
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
// Custom struct for table fields
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// Custom enum
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub enum PlayerStatus {
|
||||
Idle,
|
||||
Walking(Position),
|
||||
Fighting(Identity),
|
||||
}
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: Identity,
|
||||
pub position: Position,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Scheduled Tables
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
|
||||
|
||||
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
|
||||
pub struct CleanupJob {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
|
||||
scheduled_at: ScheduleAt,
|
||||
target_id: u64,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
|
||||
// Job row is auto-deleted after reducer completes
|
||||
log::info!("Cleaning up: {}", job.target_id);
|
||||
}
|
||||
|
||||
// Schedule a job
|
||||
#[reducer]
|
||||
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
|
||||
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
|
||||
ctx.db.cleanup_job().insert(CleanupJob {
|
||||
scheduled_id: 0, // auto-inc placeholder
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
target_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel by deleting the row
|
||||
#[reducer]
|
||||
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
|
||||
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Client SDK
|
||||
|
||||
```rust
|
||||
// Connection pattern
|
||||
let conn = DbConnection::builder()
|
||||
.with_uri("http://localhost:3000")
|
||||
.with_module_name("my-module")
|
||||
.with_token(load_saved_token()) // None for first connection
|
||||
.on_connect(on_connected)
|
||||
.build()
|
||||
.expect("Failed to connect");
|
||||
|
||||
// Subscribe in on_connect callback, NOT before!
|
||||
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ CRITICAL: Advance the Connection
|
||||
|
||||
**You MUST call one of these** — without it, no callbacks fire:
|
||||
|
||||
```rust
|
||||
conn.run_threaded(); // Background thread (simplest)
|
||||
conn.run_async().await; // Async task
|
||||
conn.frame_tick()?; // Manual polling (game loops)
|
||||
```
|
||||
|
||||
### Table Access & Callbacks
|
||||
|
||||
```rust
|
||||
// Iterate
|
||||
for user in ctx.db.user().iter() { ... }
|
||||
|
||||
// Find by primary key
|
||||
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
|
||||
|
||||
// Row callbacks
|
||||
ctx.db.user().on_insert(|ctx, user| { ... });
|
||||
ctx.db.user().on_update(|ctx, old, new| { ... });
|
||||
ctx.db.user().on_delete(|ctx, user| { ... });
|
||||
|
||||
// Call reducers
|
||||
ctx.reducers.set_name("Alice".to_string()).unwrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Procedures (Beta)
|
||||
|
||||
**Procedures are for side effects (HTTP, filesystem) that reducers can't do.**
|
||||
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
// Simple procedure
|
||||
#[procedure]
|
||||
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
|
||||
a as u64 + b as u64
|
||||
}
|
||||
|
||||
// Procedure with database access
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
// HTTP request (allowed in procedures, not reducers)
|
||||
let data = fetch_from_url(&url)?;
|
||||
|
||||
// Database access requires explicit transaction
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData {
|
||||
id: 0,
|
||||
content: data,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
|
||||
---
|
||||
|
||||
## 9) Logging
|
||||
|
||||
```rust
|
||||
use spacetimedb::log;
|
||||
|
||||
log::trace!("Detailed trace");
|
||||
log::debug!("Debug info");
|
||||
log::info!("Information");
|
||||
log::warn!("Warning");
|
||||
log::error!("Error occurred");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Commands
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
|
||||
# View logs
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Hard Requirements
|
||||
|
||||
**Rust-specific:**
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc.
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk`
|
||||
6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
7. **Use `ctx.rng`** — not `rand` crate for random numbers
|
||||
8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers
|
||||
9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()`
|
||||
10. **Subscribe in `on_connect` callback** — not before connection is established
|
||||
11. **Update requires full row** — spread existing row with `..existing`
|
||||
12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()`
|
||||
14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes
|
||||
770
.windsurfrules
Normal file
770
.windsurfrules
Normal file
@@ -0,0 +1,770 @@
|
||||
# SpacetimeDB Rules (All Languages)
|
||||
|
||||
## Migrating from 1.0 to 2.0?
|
||||
|
||||
**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
|
||||
|
||||
---
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random
|
||||
3. **Read data via tables/subscriptions** — not reducer return values
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
|
||||
5. **`ctx.sender` is the authenticated principal** — never trust identity args
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Index System
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
You can add explicit indexes on non-unique columns for query performance.
|
||||
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Login to allow remote database deployment e.g. to maincloud
|
||||
spacetime login
|
||||
|
||||
# Start local SpacetimeDB
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
|
||||
# Clear and republish
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
|
||||
# Generate client bindings
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
|
||||
# View logs
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
- The database owner can view utilization and performance metrics on the dashboard
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB Rust SDK
|
||||
|
||||
## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS
|
||||
|
||||
These are **actual errors** observed when LLMs generate SpacetimeDB Rust code:
|
||||
|
||||
### 1. Wrong Crate for Server vs Client
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using client crate for server module
|
||||
use spacetimedb_sdk::*; // This is for CLIENTS only!
|
||||
|
||||
// ✅ CORRECT — use spacetimedb for server modules
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
```
|
||||
|
||||
### 2. Wrong Table Macro Syntax
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using attribute-style like C#
|
||||
#[spacetimedb::table]
|
||||
#[primary_key]
|
||||
pub struct User { ... }
|
||||
|
||||
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
|
||||
#[derive(SpacetimeType)]
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
|
||||
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Wrong Table Access Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
|
||||
ctx.Db.user.Insert(...);
|
||||
ctx.db().user().insert(...);
|
||||
ctx.db.player; // Field access
|
||||
|
||||
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
|
||||
ctx.db.user().insert(User { ... });
|
||||
ctx.db.user().identity().find(ctx.sender);
|
||||
ctx.db.player().id().find(&player_id);
|
||||
```
|
||||
|
||||
### 4. Wrong Update Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — partial update or using .update() directly on table
|
||||
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
|
||||
|
||||
// ✅ CORRECT — find existing, spread it, update via primary key accessor
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Wrong Reducer Return Type
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returning data from reducer
|
||||
#[reducer]
|
||||
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
|
||||
|
||||
// ❌ WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
|
||||
|
||||
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
|
||||
#[reducer]
|
||||
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("Value cannot be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wrong Client Connection Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — subscribing before connected
|
||||
let conn = DbConnection::builder().build()?;
|
||||
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
|
||||
|
||||
// ✅ CORRECT — subscribe in on_connect callback
|
||||
DbConnection::builder()
|
||||
.on_connect(|conn, identity, token| {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
})
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### 7. Forgetting to Advance the Connection
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — connection never processes messages
|
||||
let conn = DbConnection::builder().build()?;
|
||||
// ... callbacks never fire ...
|
||||
|
||||
// ✅ CORRECT — must call one of these to process messages
|
||||
conn.run_threaded(); // Spawn background thread
|
||||
// OR
|
||||
conn.run_async().await; // Async task
|
||||
// OR (in game loop)
|
||||
conn.frame_tick()?; // Manual polling
|
||||
```
|
||||
|
||||
### 8. Missing Table Trait Import
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — "no method named `insert` found"
|
||||
use spacetimedb::{table, reducer, ReducerContext};
|
||||
ctx.db.user().insert(...); // ERROR!
|
||||
|
||||
// ✅ CORRECT — import Table trait for table methods
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext};
|
||||
ctx.db.user().insert(...); // Works!
|
||||
```
|
||||
|
||||
### 9. Wrong ScheduleAt Variant
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — At variant doesn't exist
|
||||
scheduled_at: ScheduleAt::At(future_time),
|
||||
|
||||
// ✅ CORRECT — use Time variant
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
```
|
||||
|
||||
### 10. Identity to String Conversion
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — to_hex() returns HexString<32>, not String
|
||||
let id: String = identity.to_hex(); // Type mismatch!
|
||||
|
||||
// ✅ CORRECT — chain .to_string()
|
||||
let id: String = identity.to_hex().to_string();
|
||||
```
|
||||
|
||||
### 11. Timestamp Duration Extraction
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returns Result, not Duration directly
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
|
||||
|
||||
// ✅ CORRECT — unwrap the Result
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_micros();
|
||||
```
|
||||
|
||||
### 12. Borrow After Move
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — `tool` moved into struct, then borrowed
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if tool == "eraser" { ... } // ERROR: value moved!
|
||||
|
||||
// ✅ CORRECT — check before move, or use clone
|
||||
let is_eraser = tool == "eraser";
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if is_eraser { ... }
|
||||
```
|
||||
|
||||
### 13. Client SDK Uses Blocking I/O
|
||||
|
||||
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor.
|
||||
|
||||
### 14. Wrong Schedule Syntax
|
||||
```rust
|
||||
// ❌ WRONG — `schedule` is not a valid table type
|
||||
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
|
||||
|
||||
// ✅ CORRECT — `scheduled` is a valid table type
|
||||
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
|
||||
```
|
||||
---
|
||||
|
||||
## 1) Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros |
|
||||
| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" |
|
||||
| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index |
|
||||
| `&mut ReducerContext` | `&ReducerContext` | Wrong context type |
|
||||
| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" |
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## 2) Table Definition (CRITICAL)
|
||||
|
||||
**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!**
|
||||
|
||||
> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
|
||||
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
|
||||
#[derive(SpacetimeType)] // REMOVE THIS!
|
||||
#[table(accessor = task)]
|
||||
pub struct Task { ... }
|
||||
|
||||
// ✅ CORRECT — just the #[table] attribute
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
|
||||
#[unique]
|
||||
username: Option<String>,
|
||||
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
sender: Identity,
|
||||
text: String,
|
||||
sent: Timestamp,
|
||||
}
|
||||
|
||||
// With multi-column index
|
||||
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
|
||||
pub struct Task {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
pub owner_id: Identity,
|
||||
pub title: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Options
|
||||
|
||||
```rust
|
||||
#[table(accessor = my_table)] // Private table (default)
|
||||
#[table(accessor = my_table, public)] // Public table - clients can subscribe
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
|
||||
```rust
|
||||
#[primary_key] // Primary key (auto-indexed, enables .find())
|
||||
#[auto_inc] // Auto-increment (use with #[primary_key])
|
||||
#[unique] // Unique constraint (auto-indexed)
|
||||
#[index(btree)] // B-Tree index for queries
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```rust
|
||||
let row = ctx.db.task().insert(Task {
|
||||
id: 0, // auto-inc placeholder
|
||||
owner_id: ctx.sender,
|
||||
title: "New task".to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
let new_id = row.id; // Get the actual ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Reducers
|
||||
|
||||
### Definition Syntax
|
||||
|
||||
```rust
|
||||
use spacetimedb::{reducer, ReducerContext, Table};
|
||||
|
||||
#[reducer]
|
||||
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
|
||||
// Validate input
|
||||
if text.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Insert returns the inserted row
|
||||
let row = ctx.db.message().insert(Message {
|
||||
id: 0, // auto-inc placeholder
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
});
|
||||
|
||||
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Pattern (CRITICAL)
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
// Find existing row
|
||||
let user = ctx.db.user().identity().find(ctx.sender)
|
||||
.ok_or("User not found")?;
|
||||
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
ctx.db.user().identity().update(User {
|
||||
name: Some(name),
|
||||
..user // Preserves identity, online, etc.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
|
||||
```
|
||||
|
||||
### Delete Pattern
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
|
||||
ctx.db.message().id().delete(&message_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```rust
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
// Called when module is first published
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
// ctx.sender is the connecting identity
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: ctx.sender,
|
||||
username: None,
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ReducerContext fields
|
||||
|
||||
```rust
|
||||
ctx.sender // Identity of the caller
|
||||
ctx.timestamp // Current timestamp
|
||||
ctx.db // Database access
|
||||
ctx.rng // Deterministic RNG (use instead of rand)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Index Access
|
||||
|
||||
### Primary Key / Unique — `.find()` returns `Option<Row>`
|
||||
|
||||
```rust
|
||||
// Primary key lookup
|
||||
let user = ctx.db.user().identity().find(ctx.sender);
|
||||
|
||||
// Unique column lookup
|
||||
let user = ctx.db.user().username().find(&"alice".to_string());
|
||||
|
||||
if let Some(user) = user {
|
||||
// Found
|
||||
}
|
||||
```
|
||||
|
||||
### BTree Index — `.filter()` returns iterator
|
||||
|
||||
```rust
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
#[index(btree)]
|
||||
room_id: u64,
|
||||
|
||||
text: String,
|
||||
}
|
||||
|
||||
// Filter by indexed column
|
||||
for msg in ctx.db.message().room_id().filter(&room_id) {
|
||||
// Process each message in room
|
||||
}
|
||||
```
|
||||
|
||||
### No Index — `.iter()` + manual filter
|
||||
|
||||
```rust
|
||||
// Full table scan
|
||||
for user in ctx.db.user().iter() {
|
||||
if user.online {
|
||||
// Process online users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Custom Types
|
||||
|
||||
**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.**
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
// Custom struct for table fields
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// Custom enum
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub enum PlayerStatus {
|
||||
Idle,
|
||||
Walking(Position),
|
||||
Fighting(Identity),
|
||||
}
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: Identity,
|
||||
pub position: Position,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Scheduled Tables
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
|
||||
|
||||
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
|
||||
pub struct CleanupJob {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
|
||||
scheduled_at: ScheduleAt,
|
||||
target_id: u64,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
|
||||
// Job row is auto-deleted after reducer completes
|
||||
log::info!("Cleaning up: {}", job.target_id);
|
||||
}
|
||||
|
||||
// Schedule a job
|
||||
#[reducer]
|
||||
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
|
||||
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
|
||||
ctx.db.cleanup_job().insert(CleanupJob {
|
||||
scheduled_id: 0, // auto-inc placeholder
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
target_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel by deleting the row
|
||||
#[reducer]
|
||||
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
|
||||
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Client SDK
|
||||
|
||||
```rust
|
||||
// Connection pattern
|
||||
let conn = DbConnection::builder()
|
||||
.with_uri("http://localhost:3000")
|
||||
.with_module_name("my-module")
|
||||
.with_token(load_saved_token()) // None for first connection
|
||||
.on_connect(on_connected)
|
||||
.build()
|
||||
.expect("Failed to connect");
|
||||
|
||||
// Subscribe in on_connect callback, NOT before!
|
||||
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ CRITICAL: Advance the Connection
|
||||
|
||||
**You MUST call one of these** — without it, no callbacks fire:
|
||||
|
||||
```rust
|
||||
conn.run_threaded(); // Background thread (simplest)
|
||||
conn.run_async().await; // Async task
|
||||
conn.frame_tick()?; // Manual polling (game loops)
|
||||
```
|
||||
|
||||
### Table Access & Callbacks
|
||||
|
||||
```rust
|
||||
// Iterate
|
||||
for user in ctx.db.user().iter() { ... }
|
||||
|
||||
// Find by primary key
|
||||
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
|
||||
|
||||
// Row callbacks
|
||||
ctx.db.user().on_insert(|ctx, user| { ... });
|
||||
ctx.db.user().on_update(|ctx, old, new| { ... });
|
||||
ctx.db.user().on_delete(|ctx, user| { ... });
|
||||
|
||||
// Call reducers
|
||||
ctx.reducers.set_name("Alice".to_string()).unwrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Procedures (Beta)
|
||||
|
||||
**Procedures are for side effects (HTTP, filesystem) that reducers can't do.**
|
||||
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
// Simple procedure
|
||||
#[procedure]
|
||||
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
|
||||
a as u64 + b as u64
|
||||
}
|
||||
|
||||
// Procedure with database access
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
// HTTP request (allowed in procedures, not reducers)
|
||||
let data = fetch_from_url(&url)?;
|
||||
|
||||
// Database access requires explicit transaction
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData {
|
||||
id: 0,
|
||||
content: data,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
|
||||
---
|
||||
|
||||
## 9) Logging
|
||||
|
||||
```rust
|
||||
use spacetimedb::log;
|
||||
|
||||
log::trace!("Detailed trace");
|
||||
log::debug!("Debug info");
|
||||
log::info!("Information");
|
||||
log::warn!("Warning");
|
||||
log::error!("Error occurred");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Commands
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
|
||||
# View logs
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Hard Requirements
|
||||
|
||||
**Rust-specific:**
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc.
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk`
|
||||
6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
7. **Use `ctx.rng`** — not `rand` crate for random numbers
|
||||
8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers
|
||||
9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()`
|
||||
10. **Subscribe in `on_connect` callback** — not before connection is established
|
||||
11. **Update requires full row** — spread existing row with `..existing`
|
||||
12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()`
|
||||
14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes
|
||||
771
AGENTS.md
771
AGENTS.md
@@ -91,3 +91,774 @@ docs/
|
||||
├─ PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md
|
||||
└─ SERVER_DEPLOYMENT_AND_CORS_TECHNICAL_SOLUTION_2026-04-05.md
|
||||
```
|
||||
|
||||
# SpacetimeDB Rules (All Languages)
|
||||
|
||||
## Migrating from 1.0 to 2.0?
|
||||
|
||||
**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
|
||||
|
||||
---
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random
|
||||
3. **Read data via tables/subscriptions** — not reducer return values
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
|
||||
5. **`ctx.sender` is the authenticated principal** — never trust identity args
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Index System
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
You can add explicit indexes on non-unique columns for query performance.
|
||||
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Login to allow remote database deployment e.g. to maincloud
|
||||
spacetime login
|
||||
|
||||
# Start local SpacetimeDB
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
|
||||
# Clear and republish
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
|
||||
# Generate client bindings
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
|
||||
# View logs
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
- The database owner can view utilization and performance metrics on the dashboard
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB Rust SDK
|
||||
|
||||
## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS
|
||||
|
||||
These are **actual errors** observed when LLMs generate SpacetimeDB Rust code:
|
||||
|
||||
### 1. Wrong Crate for Server vs Client
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using client crate for server module
|
||||
use spacetimedb_sdk::*; // This is for CLIENTS only!
|
||||
|
||||
// ✅ CORRECT — use spacetimedb for server modules
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
```
|
||||
|
||||
### 2. Wrong Table Macro Syntax
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using attribute-style like C#
|
||||
#[spacetimedb::table]
|
||||
#[primary_key]
|
||||
pub struct User { ... }
|
||||
|
||||
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
|
||||
#[derive(SpacetimeType)]
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
|
||||
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Wrong Table Access Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
|
||||
ctx.Db.user.Insert(...);
|
||||
ctx.db().user().insert(...);
|
||||
ctx.db.player; // Field access
|
||||
|
||||
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
|
||||
ctx.db.user().insert(User { ... });
|
||||
ctx.db.user().identity().find(ctx.sender);
|
||||
ctx.db.player().id().find(&player_id);
|
||||
```
|
||||
|
||||
### 4. Wrong Update Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — partial update or using .update() directly on table
|
||||
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
|
||||
|
||||
// ✅ CORRECT — find existing, spread it, update via primary key accessor
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Wrong Reducer Return Type
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returning data from reducer
|
||||
#[reducer]
|
||||
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
|
||||
|
||||
// ❌ WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
|
||||
|
||||
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
|
||||
#[reducer]
|
||||
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("Value cannot be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wrong Client Connection Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — subscribing before connected
|
||||
let conn = DbConnection::builder().build()?;
|
||||
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
|
||||
|
||||
// ✅ CORRECT — subscribe in on_connect callback
|
||||
DbConnection::builder()
|
||||
.on_connect(|conn, identity, token| {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
})
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### 7. Forgetting to Advance the Connection
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — connection never processes messages
|
||||
let conn = DbConnection::builder().build()?;
|
||||
// ... callbacks never fire ...
|
||||
|
||||
// ✅ CORRECT — must call one of these to process messages
|
||||
conn.run_threaded(); // Spawn background thread
|
||||
// OR
|
||||
conn.run_async().await; // Async task
|
||||
// OR (in game loop)
|
||||
conn.frame_tick()?; // Manual polling
|
||||
```
|
||||
|
||||
### 8. Missing Table Trait Import
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — "no method named `insert` found"
|
||||
use spacetimedb::{table, reducer, ReducerContext};
|
||||
ctx.db.user().insert(...); // ERROR!
|
||||
|
||||
// ✅ CORRECT — import Table trait for table methods
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext};
|
||||
ctx.db.user().insert(...); // Works!
|
||||
```
|
||||
|
||||
### 9. Wrong ScheduleAt Variant
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — At variant doesn't exist
|
||||
scheduled_at: ScheduleAt::At(future_time),
|
||||
|
||||
// ✅ CORRECT — use Time variant
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
```
|
||||
|
||||
### 10. Identity to String Conversion
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — to_hex() returns HexString<32>, not String
|
||||
let id: String = identity.to_hex(); // Type mismatch!
|
||||
|
||||
// ✅ CORRECT — chain .to_string()
|
||||
let id: String = identity.to_hex().to_string();
|
||||
```
|
||||
|
||||
### 11. Timestamp Duration Extraction
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returns Result, not Duration directly
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
|
||||
|
||||
// ✅ CORRECT — unwrap the Result
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_micros();
|
||||
```
|
||||
|
||||
### 12. Borrow After Move
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — `tool` moved into struct, then borrowed
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if tool == "eraser" { ... } // ERROR: value moved!
|
||||
|
||||
// ✅ CORRECT — check before move, or use clone
|
||||
let is_eraser = tool == "eraser";
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if is_eraser { ... }
|
||||
```
|
||||
|
||||
### 13. Client SDK Uses Blocking I/O
|
||||
|
||||
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor.
|
||||
|
||||
### 14. Wrong Schedule Syntax
|
||||
```rust
|
||||
// ❌ WRONG — `schedule` is not a valid table type
|
||||
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
|
||||
|
||||
// ✅ CORRECT — `scheduled` is a valid table type
|
||||
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
|
||||
```
|
||||
---
|
||||
|
||||
## 1) Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros |
|
||||
| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" |
|
||||
| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index |
|
||||
| `&mut ReducerContext` | `&ReducerContext` | Wrong context type |
|
||||
| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" |
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## 2) Table Definition (CRITICAL)
|
||||
|
||||
**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!**
|
||||
|
||||
> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
|
||||
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
|
||||
#[derive(SpacetimeType)] // REMOVE THIS!
|
||||
#[table(accessor = task)]
|
||||
pub struct Task { ... }
|
||||
|
||||
// ✅ CORRECT — just the #[table] attribute
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
|
||||
#[unique]
|
||||
username: Option<String>,
|
||||
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
sender: Identity,
|
||||
text: String,
|
||||
sent: Timestamp,
|
||||
}
|
||||
|
||||
// With multi-column index
|
||||
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
|
||||
pub struct Task {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
pub owner_id: Identity,
|
||||
pub title: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Options
|
||||
|
||||
```rust
|
||||
#[table(accessor = my_table)] // Private table (default)
|
||||
#[table(accessor = my_table, public)] // Public table - clients can subscribe
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
|
||||
```rust
|
||||
#[primary_key] // Primary key (auto-indexed, enables .find())
|
||||
#[auto_inc] // Auto-increment (use with #[primary_key])
|
||||
#[unique] // Unique constraint (auto-indexed)
|
||||
#[index(btree)] // B-Tree index for queries
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```rust
|
||||
let row = ctx.db.task().insert(Task {
|
||||
id: 0, // auto-inc placeholder
|
||||
owner_id: ctx.sender,
|
||||
title: "New task".to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
let new_id = row.id; // Get the actual ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Reducers
|
||||
|
||||
### Definition Syntax
|
||||
|
||||
```rust
|
||||
use spacetimedb::{reducer, ReducerContext, Table};
|
||||
|
||||
#[reducer]
|
||||
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
|
||||
// Validate input
|
||||
if text.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Insert returns the inserted row
|
||||
let row = ctx.db.message().insert(Message {
|
||||
id: 0, // auto-inc placeholder
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
});
|
||||
|
||||
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Pattern (CRITICAL)
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
// Find existing row
|
||||
let user = ctx.db.user().identity().find(ctx.sender)
|
||||
.ok_or("User not found")?;
|
||||
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
ctx.db.user().identity().update(User {
|
||||
name: Some(name),
|
||||
..user // Preserves identity, online, etc.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
|
||||
```
|
||||
|
||||
### Delete Pattern
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
|
||||
ctx.db.message().id().delete(&message_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```rust
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
// Called when module is first published
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
// ctx.sender is the connecting identity
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: ctx.sender,
|
||||
username: None,
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ReducerContext fields
|
||||
|
||||
```rust
|
||||
ctx.sender // Identity of the caller
|
||||
ctx.timestamp // Current timestamp
|
||||
ctx.db // Database access
|
||||
ctx.rng // Deterministic RNG (use instead of rand)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Index Access
|
||||
|
||||
### Primary Key / Unique — `.find()` returns `Option<Row>`
|
||||
|
||||
```rust
|
||||
// Primary key lookup
|
||||
let user = ctx.db.user().identity().find(ctx.sender);
|
||||
|
||||
// Unique column lookup
|
||||
let user = ctx.db.user().username().find(&"alice".to_string());
|
||||
|
||||
if let Some(user) = user {
|
||||
// Found
|
||||
}
|
||||
```
|
||||
|
||||
### BTree Index — `.filter()` returns iterator
|
||||
|
||||
```rust
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
#[index(btree)]
|
||||
room_id: u64,
|
||||
|
||||
text: String,
|
||||
}
|
||||
|
||||
// Filter by indexed column
|
||||
for msg in ctx.db.message().room_id().filter(&room_id) {
|
||||
// Process each message in room
|
||||
}
|
||||
```
|
||||
|
||||
### No Index — `.iter()` + manual filter
|
||||
|
||||
```rust
|
||||
// Full table scan
|
||||
for user in ctx.db.user().iter() {
|
||||
if user.online {
|
||||
// Process online users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Custom Types
|
||||
|
||||
**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.**
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
// Custom struct for table fields
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// Custom enum
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub enum PlayerStatus {
|
||||
Idle,
|
||||
Walking(Position),
|
||||
Fighting(Identity),
|
||||
}
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: Identity,
|
||||
pub position: Position,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Scheduled Tables
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
|
||||
|
||||
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
|
||||
pub struct CleanupJob {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
|
||||
scheduled_at: ScheduleAt,
|
||||
target_id: u64,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
|
||||
// Job row is auto-deleted after reducer completes
|
||||
log::info!("Cleaning up: {}", job.target_id);
|
||||
}
|
||||
|
||||
// Schedule a job
|
||||
#[reducer]
|
||||
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
|
||||
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
|
||||
ctx.db.cleanup_job().insert(CleanupJob {
|
||||
scheduled_id: 0, // auto-inc placeholder
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
target_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel by deleting the row
|
||||
#[reducer]
|
||||
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
|
||||
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Client SDK
|
||||
|
||||
```rust
|
||||
// Connection pattern
|
||||
let conn = DbConnection::builder()
|
||||
.with_uri("http://localhost:3000")
|
||||
.with_module_name("my-module")
|
||||
.with_token(load_saved_token()) // None for first connection
|
||||
.on_connect(on_connected)
|
||||
.build()
|
||||
.expect("Failed to connect");
|
||||
|
||||
// Subscribe in on_connect callback, NOT before!
|
||||
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ CRITICAL: Advance the Connection
|
||||
|
||||
**You MUST call one of these** — without it, no callbacks fire:
|
||||
|
||||
```rust
|
||||
conn.run_threaded(); // Background thread (simplest)
|
||||
conn.run_async().await; // Async task
|
||||
conn.frame_tick()?; // Manual polling (game loops)
|
||||
```
|
||||
|
||||
### Table Access & Callbacks
|
||||
|
||||
```rust
|
||||
// Iterate
|
||||
for user in ctx.db.user().iter() { ... }
|
||||
|
||||
// Find by primary key
|
||||
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
|
||||
|
||||
// Row callbacks
|
||||
ctx.db.user().on_insert(|ctx, user| { ... });
|
||||
ctx.db.user().on_update(|ctx, old, new| { ... });
|
||||
ctx.db.user().on_delete(|ctx, user| { ... });
|
||||
|
||||
// Call reducers
|
||||
ctx.reducers.set_name("Alice".to_string()).unwrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Procedures (Beta)
|
||||
|
||||
**Procedures are for side effects (HTTP, filesystem) that reducers can't do.**
|
||||
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
// Simple procedure
|
||||
#[procedure]
|
||||
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
|
||||
a as u64 + b as u64
|
||||
}
|
||||
|
||||
// Procedure with database access
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
// HTTP request (allowed in procedures, not reducers)
|
||||
let data = fetch_from_url(&url)?;
|
||||
|
||||
// Database access requires explicit transaction
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData {
|
||||
id: 0,
|
||||
content: data,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
|
||||
---
|
||||
|
||||
## 9) Logging
|
||||
|
||||
```rust
|
||||
use spacetimedb::log;
|
||||
|
||||
log::trace!("Detailed trace");
|
||||
log::debug!("Debug info");
|
||||
log::info!("Information");
|
||||
log::warn!("Warning");
|
||||
log::error!("Error occurred");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Commands
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
|
||||
# View logs
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Hard Requirements
|
||||
|
||||
**Rust-specific:**
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc.
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk`
|
||||
6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
7. **Use `ctx.rng`** — not `rand` crate for random numbers
|
||||
8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers
|
||||
9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()`
|
||||
10. **Subscribe in `on_connect` callback** — not before connection is established
|
||||
11. **Update requires full row** — spread existing row with `..existing`
|
||||
12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()`
|
||||
14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes
|
||||
|
||||
770
CLAUDE.md
Normal file
770
CLAUDE.md
Normal file
@@ -0,0 +1,770 @@
|
||||
# SpacetimeDB Rules (All Languages)
|
||||
|
||||
## Migrating from 1.0 to 2.0?
|
||||
|
||||
**If you are migrating existing SpacetimeDB 1.0 code to 2.0, apply `spacetimedb-migration-2.0.mdc` first.** It documents breaking changes (reducer callbacks → event tables, `name`→`accessor`, `sender()` method, etc.) and should be considered before other rules.
|
||||
|
||||
---
|
||||
|
||||
## Language-Specific Rules
|
||||
|
||||
| Language | Rule File |
|
||||
|----------|-----------|
|
||||
| **TypeScript/React** | `spacetimedb-typescript.mdc` (MANDATORY) |
|
||||
| **Rust** | `spacetimedb-rust.mdc` (MANDATORY) |
|
||||
| **C#** | `spacetimedb-csharp.mdc` (MANDATORY) |
|
||||
| **Migrating 1.0 → 2.0** | `spacetimedb-migration-2.0.mdc` |
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
1. **Reducers are transactional** — they do not return data to callers
|
||||
2. **Reducers must be deterministic** — no filesystem, network, timers, or random
|
||||
3. **Read data via tables/subscriptions** — not reducer return values
|
||||
4. **Auto-increment IDs are not sequential** — gaps are normal, don't use for ordering
|
||||
5. **`ctx.sender` is the authenticated principal** — never trust identity args
|
||||
|
||||
---
|
||||
|
||||
## Feature Implementation Checklist
|
||||
|
||||
When implementing a feature that spans backend and client:
|
||||
|
||||
1. **Backend:** Define table(s) to store the data
|
||||
2. **Backend:** Define reducer(s) to mutate the data
|
||||
3. **Client:** Subscribe to the table(s)
|
||||
4. **Client:** Call the reducer(s) from UI — **don't forget this step!**
|
||||
5. **Client:** Render the data from the table(s)
|
||||
|
||||
**Common mistake:** Building backend tables/reducers but forgetting to wire up the client to call them.
|
||||
|
||||
---
|
||||
|
||||
## Index System
|
||||
|
||||
SpacetimeDB automatically creates indexes for:
|
||||
- Primary key columns
|
||||
- Columns marked as unique
|
||||
|
||||
You can add explicit indexes on non-unique columns for query performance.
|
||||
|
||||
**Index names must be unique across your entire module (all tables).** If two tables have indexes with the same declared name → conflict error.
|
||||
|
||||
**Schema ↔ Code coupling:**
|
||||
- Your query code references indexes by name
|
||||
- If you add/remove/rename an index in the schema, update all code that uses it
|
||||
- Removing an index without updating queries causes runtime errors
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Login to allow remote database deployment e.g. to maincloud
|
||||
spacetime login
|
||||
|
||||
# Start local SpacetimeDB
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <db-name> --module-path <module-path>
|
||||
|
||||
# Clear and republish
|
||||
spacetime publish <db-name> --clear-database -y --module-path <module-path>
|
||||
|
||||
# Generate client bindings
|
||||
spacetime generate --lang <lang> --out-dir <out> --module-path <module-path>
|
||||
|
||||
# View logs
|
||||
spacetime logs <db-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- Maincloud is the spacetimedb hosted cloud and the default location for module publishing
|
||||
- The default server marked by *** in `spacetime server list` should be used when publishing
|
||||
- If the default server is maincloud you should publish to maincloud
|
||||
- Publishing to maincloud is free of charge
|
||||
- When publishing to maincloud the database dashboard will be at the url: https://spacetimedb.com/@<username>/<database-name>
|
||||
- The database owner can view utilization and performance metrics on the dashboard
|
||||
|
||||
---
|
||||
|
||||
## Debugging Checklist
|
||||
|
||||
1. Is SpacetimeDB server running? (`spacetime start`)
|
||||
2. Is the module published? (`spacetime publish`)
|
||||
3. Are client bindings generated? (`spacetime generate`)
|
||||
4. Check server logs for errors (`spacetime logs <db-name>`)
|
||||
5. **Is the reducer actually being called from the client?**
|
||||
|
||||
---
|
||||
|
||||
## Editing Behavior
|
||||
|
||||
- Make the smallest change necessary
|
||||
- Do NOT touch unrelated files, configs, or dependencies
|
||||
- Do NOT invent new SpacetimeDB APIs — use only what exists in docs or this repo
|
||||
- Do NOT add restrictions the prompt didn't ask for — if "users can do X", implement X for all users
|
||||
|
||||
|
||||
# SpacetimeDB Rust SDK
|
||||
|
||||
## ⛔ COMMON MISTAKES — LLM HALLUCINATIONS
|
||||
|
||||
These are **actual errors** observed when LLMs generate SpacetimeDB Rust code:
|
||||
|
||||
### 1. Wrong Crate for Server vs Client
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using client crate for server module
|
||||
use spacetimedb_sdk::*; // This is for CLIENTS only!
|
||||
|
||||
// ✅ CORRECT — use spacetimedb for server modules
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
```
|
||||
|
||||
### 2. Wrong Table Macro Syntax
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using attribute-style like C#
|
||||
#[spacetimedb::table]
|
||||
#[primary_key]
|
||||
pub struct User { ... }
|
||||
|
||||
// ❌ WRONG — SpacetimeType on tables (causes conflicts!)
|
||||
#[derive(SpacetimeType)]
|
||||
#[table(accessor = my_table)]
|
||||
pub struct MyTable { ... }
|
||||
|
||||
// ✅ CORRECT — use #[table(...)] macro with options, NO SpacetimeType
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
name: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Wrong Table Access Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — using ctx.Db or ctx.db() method or field access
|
||||
ctx.Db.user.Insert(...);
|
||||
ctx.db().user().insert(...);
|
||||
ctx.db.player; // Field access
|
||||
|
||||
// ✅ CORRECT — ctx.db is a field, table names are methods with parentheses
|
||||
ctx.db.user().insert(User { ... });
|
||||
ctx.db.user().identity().find(ctx.sender);
|
||||
ctx.db.player().id().find(&player_id);
|
||||
```
|
||||
|
||||
### 4. Wrong Update Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — partial update or using .update() directly on table
|
||||
ctx.db.user().update(User { name: Some("new".into()), ..Default::default() });
|
||||
|
||||
// ✅ CORRECT — find existing, spread it, update via primary key accessor
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { name: Some("new".into()), ..user });
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Wrong Reducer Return Type
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returning data from reducer
|
||||
#[reducer]
|
||||
pub fn get_user(ctx: &ReducerContext, id: Identity) -> Option<User> { ... }
|
||||
|
||||
// ❌ WRONG — mutable context
|
||||
pub fn my_reducer(ctx: &mut ReducerContext, ...) { }
|
||||
|
||||
// ✅ CORRECT — reducers return Result<(), String> or nothing, immutable context
|
||||
#[reducer]
|
||||
pub fn do_something(ctx: &ReducerContext, value: String) -> Result<(), String> {
|
||||
if value.is_empty() {
|
||||
return Err("Value cannot be empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Wrong Client Connection Pattern
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — subscribing before connected
|
||||
let conn = DbConnection::builder().build()?;
|
||||
conn.subscription_builder().subscribe_to_all_tables(); // NOT CONNECTED YET!
|
||||
|
||||
// ✅ CORRECT — subscribe in on_connect callback
|
||||
DbConnection::builder()
|
||||
.on_connect(|conn, identity, token| {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
})
|
||||
.build()?;
|
||||
```
|
||||
|
||||
### 7. Forgetting to Advance the Connection
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — connection never processes messages
|
||||
let conn = DbConnection::builder().build()?;
|
||||
// ... callbacks never fire ...
|
||||
|
||||
// ✅ CORRECT — must call one of these to process messages
|
||||
conn.run_threaded(); // Spawn background thread
|
||||
// OR
|
||||
conn.run_async().await; // Async task
|
||||
// OR (in game loop)
|
||||
conn.frame_tick()?; // Manual polling
|
||||
```
|
||||
|
||||
### 8. Missing Table Trait Import
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — "no method named `insert` found"
|
||||
use spacetimedb::{table, reducer, ReducerContext};
|
||||
ctx.db.user().insert(...); // ERROR!
|
||||
|
||||
// ✅ CORRECT — import Table trait for table methods
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext};
|
||||
ctx.db.user().insert(...); // Works!
|
||||
```
|
||||
|
||||
### 9. Wrong ScheduleAt Variant
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — At variant doesn't exist
|
||||
scheduled_at: ScheduleAt::At(future_time),
|
||||
|
||||
// ✅ CORRECT — use Time variant
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
```
|
||||
|
||||
### 10. Identity to String Conversion
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — to_hex() returns HexString<32>, not String
|
||||
let id: String = identity.to_hex(); // Type mismatch!
|
||||
|
||||
// ✅ CORRECT — chain .to_string()
|
||||
let id: String = identity.to_hex().to_string();
|
||||
```
|
||||
|
||||
### 11. Timestamp Duration Extraction
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — returns Result, not Duration directly
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch().as_micros();
|
||||
|
||||
// ✅ CORRECT — unwrap the Result
|
||||
let micros = ctx.timestamp.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_micros();
|
||||
```
|
||||
|
||||
### 12. Borrow After Move
|
||||
|
||||
```rust
|
||||
// ❌ WRONG — `tool` moved into struct, then borrowed
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if tool == "eraser" { ... } // ERROR: value moved!
|
||||
|
||||
// ✅ CORRECT — check before move, or use clone
|
||||
let is_eraser = tool == "eraser";
|
||||
ctx.db.stroke().insert(Stroke { tool, color, ... });
|
||||
if is_eraser { ... }
|
||||
```
|
||||
|
||||
### 13. Client SDK Uses Blocking I/O
|
||||
|
||||
The SpacetimeDB Rust client SDK uses blocking I/O. If mixing with async runtimes (Tokio, async-std), use `spawn_blocking` or run the SDK on a dedicated thread to avoid blocking the async executor.
|
||||
|
||||
### 14. Wrong Schedule Syntax
|
||||
```rust
|
||||
// ❌ WRONG — `schedule` is not a valid table type
|
||||
#[table(name = tick_timer, schedule(reducer = tick, column = scheduled_at))]
|
||||
|
||||
// ✅ CORRECT — `scheduled` is a valid table type
|
||||
#[table(name = tick_timer, scheduled(reducer = tick, column = scheduled_at))]
|
||||
```
|
||||
---
|
||||
|
||||
## 1) Common Mistakes Table
|
||||
|
||||
### Server-side errors
|
||||
|
||||
| Wrong | Right | Error |
|
||||
|-------|-------|-------|
|
||||
| `#[derive(SpacetimeType)]` on `#[table]` | Remove it — macro handles this | Conflicting derive macros |
|
||||
| `ctx.db.player` (field access) | `ctx.db.player()` (method) | "no field `player` on type" |
|
||||
| `ctx.db.player().find(id)` | `ctx.db.player().id().find(&id)` | Must access via index |
|
||||
| `&mut ReducerContext` | `&ReducerContext` | Wrong context type |
|
||||
| Missing `use spacetimedb::Table;` | Add import | "no method named `insert`" |
|
||||
| `#[table(accessor = "my_table")]` | `#[table(accessor = my_table)]` | String literals not allowed |
|
||||
| Missing `public` on table | Add `public` flag | Clients can't subscribe |
|
||||
| `#[spacetimedb::reducer]` | `#[reducer]` after import | Wrong attribute path |
|
||||
| Network/filesystem in reducer | Use procedures instead | Sandbox violation |
|
||||
| Panic for expected errors | Return `Result<(), String>` | WASM instance destroyed |
|
||||
|
||||
---
|
||||
|
||||
## 2) Table Definition (CRITICAL)
|
||||
|
||||
**Tables use `#[table(...)]` macro on `pub struct`. DO NOT derive `SpacetimeType` on tables!**
|
||||
|
||||
> ⚠️ **CRITICAL:** Always import `Table` trait — required for `.insert()`, `.iter()`, `.find()`, etc.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, Table, ReducerContext, Identity, Timestamp};
|
||||
|
||||
// ❌ WRONG — DO NOT derive SpacetimeType on tables!
|
||||
#[derive(SpacetimeType)] // REMOVE THIS!
|
||||
#[table(accessor = task)]
|
||||
pub struct Task { ... }
|
||||
|
||||
// ✅ CORRECT — just the #[table] attribute
|
||||
#[table(accessor = user, public)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
identity: Identity,
|
||||
|
||||
#[unique]
|
||||
username: Option<String>,
|
||||
|
||||
online: bool,
|
||||
}
|
||||
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
sender: Identity,
|
||||
text: String,
|
||||
sent: Timestamp,
|
||||
}
|
||||
|
||||
// With multi-column index
|
||||
#[table(accessor = task, public, index(name = by_owner, btree(columns = [owner_id])))]
|
||||
pub struct Task {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
pub owner_id: Identity,
|
||||
pub title: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Table Options
|
||||
|
||||
```rust
|
||||
#[table(accessor = my_table)] // Private table (default)
|
||||
#[table(accessor = my_table, public)] // Public table - clients can subscribe
|
||||
```
|
||||
|
||||
### Column Attributes
|
||||
|
||||
```rust
|
||||
#[primary_key] // Primary key (auto-indexed, enables .find())
|
||||
#[auto_inc] // Auto-increment (use with #[primary_key])
|
||||
#[unique] // Unique constraint (auto-indexed)
|
||||
#[index(btree)] // B-Tree index for queries
|
||||
```
|
||||
|
||||
### Insert returns ROW, not ID
|
||||
|
||||
```rust
|
||||
let row = ctx.db.task().insert(Task {
|
||||
id: 0, // auto-inc placeholder
|
||||
owner_id: ctx.sender,
|
||||
title: "New task".to_string(),
|
||||
created_at: ctx.timestamp,
|
||||
});
|
||||
let new_id = row.id; // Get the actual ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3) Reducers
|
||||
|
||||
### Definition Syntax
|
||||
|
||||
```rust
|
||||
use spacetimedb::{reducer, ReducerContext, Table};
|
||||
|
||||
#[reducer]
|
||||
pub fn send_message(ctx: &ReducerContext, text: String) -> Result<(), String> {
|
||||
// Validate input
|
||||
if text.is_empty() {
|
||||
return Err("Message cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Insert returns the inserted row
|
||||
let row = ctx.db.message().insert(Message {
|
||||
id: 0, // auto-inc placeholder
|
||||
sender: ctx.sender,
|
||||
text,
|
||||
sent: ctx.timestamp,
|
||||
});
|
||||
|
||||
log::info!("Message {} sent by {:?}", row.id, ctx.sender);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Pattern (CRITICAL)
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn set_name(ctx: &ReducerContext, name: String) -> Result<(), String> {
|
||||
// Find existing row
|
||||
let user = ctx.db.user().identity().find(ctx.sender)
|
||||
.ok_or("User not found")?;
|
||||
|
||||
// ✅ CORRECT — spread existing row, override specific fields
|
||||
ctx.db.user().identity().update(User {
|
||||
name: Some(name),
|
||||
..user // Preserves identity, online, etc.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ❌ WRONG — partial update nulls out other fields!
|
||||
// ctx.db.user().identity().update(User { identity: ctx.sender, name: Some(name), ..Default::default() });
|
||||
```
|
||||
|
||||
### Delete Pattern
|
||||
|
||||
```rust
|
||||
#[reducer]
|
||||
pub fn delete_message(ctx: &ReducerContext, message_id: u64) -> Result<(), String> {
|
||||
ctx.db.message().id().delete(&message_id);
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
```rust
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
// Called when module is first published
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
// ctx.sender is the connecting identity
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: true, ..user });
|
||||
} else {
|
||||
ctx.db.user().insert(User {
|
||||
identity: ctx.sender,
|
||||
username: None,
|
||||
online: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(ctx: &ReducerContext) {
|
||||
if let Some(user) = ctx.db.user().identity().find(ctx.sender) {
|
||||
ctx.db.user().identity().update(User { online: false, ..user });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ReducerContext fields
|
||||
|
||||
```rust
|
||||
ctx.sender // Identity of the caller
|
||||
ctx.timestamp // Current timestamp
|
||||
ctx.db // Database access
|
||||
ctx.rng // Deterministic RNG (use instead of rand)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Index Access
|
||||
|
||||
### Primary Key / Unique — `.find()` returns `Option<Row>`
|
||||
|
||||
```rust
|
||||
// Primary key lookup
|
||||
let user = ctx.db.user().identity().find(ctx.sender);
|
||||
|
||||
// Unique column lookup
|
||||
let user = ctx.db.user().username().find(&"alice".to_string());
|
||||
|
||||
if let Some(user) = user {
|
||||
// Found
|
||||
}
|
||||
```
|
||||
|
||||
### BTree Index — `.filter()` returns iterator
|
||||
|
||||
```rust
|
||||
#[table(accessor = message, public)]
|
||||
pub struct Message {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
id: u64,
|
||||
|
||||
#[index(btree)]
|
||||
room_id: u64,
|
||||
|
||||
text: String,
|
||||
}
|
||||
|
||||
// Filter by indexed column
|
||||
for msg in ctx.db.message().room_id().filter(&room_id) {
|
||||
// Process each message in room
|
||||
}
|
||||
```
|
||||
|
||||
### No Index — `.iter()` + manual filter
|
||||
|
||||
```rust
|
||||
// Full table scan
|
||||
for user in ctx.db.user().iter() {
|
||||
if user.online {
|
||||
// Process online users
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Custom Types
|
||||
|
||||
**Use `#[derive(SpacetimeType)]` ONLY for custom structs/enums used as fields or parameters.**
|
||||
|
||||
```rust
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
// Custom struct for table fields
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub struct Position {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
}
|
||||
|
||||
// Custom enum
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq)]
|
||||
pub enum PlayerStatus {
|
||||
Idle,
|
||||
Walking(Position),
|
||||
Fighting(Identity),
|
||||
}
|
||||
|
||||
// Use in table (DO NOT derive SpacetimeType on the table!)
|
||||
#[table(accessor = player, public)]
|
||||
pub struct Player {
|
||||
#[primary_key]
|
||||
pub id: Identity,
|
||||
pub position: Position,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Scheduled Tables
|
||||
|
||||
```rust
|
||||
use spacetimedb::{table, reducer, ReducerContext, ScheduleAt, Timestamp};
|
||||
|
||||
#[table(accessor = cleanup_job, scheduled(cleanup_expired))]
|
||||
pub struct CleanupJob {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
scheduled_id: u64,
|
||||
|
||||
scheduled_at: ScheduleAt,
|
||||
target_id: u64,
|
||||
}
|
||||
|
||||
#[reducer]
|
||||
pub fn cleanup_expired(ctx: &ReducerContext, job: CleanupJob) {
|
||||
// Job row is auto-deleted after reducer completes
|
||||
log::info!("Cleaning up: {}", job.target_id);
|
||||
}
|
||||
|
||||
// Schedule a job
|
||||
#[reducer]
|
||||
pub fn schedule_cleanup(ctx: &ReducerContext, target_id: u64, delay_ms: u64) {
|
||||
let future_time = ctx.timestamp + std::time::Duration::from_millis(delay_ms);
|
||||
ctx.db.cleanup_job().insert(CleanupJob {
|
||||
scheduled_id: 0, // auto-inc placeholder
|
||||
scheduled_at: ScheduleAt::Time(future_time),
|
||||
target_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel by deleting the row
|
||||
#[reducer]
|
||||
pub fn cancel_cleanup(ctx: &ReducerContext, job_id: u64) {
|
||||
ctx.db.cleanup_job().scheduled_id().delete(&job_id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Client SDK
|
||||
|
||||
```rust
|
||||
// Connection pattern
|
||||
let conn = DbConnection::builder()
|
||||
.with_uri("http://localhost:3000")
|
||||
.with_module_name("my-module")
|
||||
.with_token(load_saved_token()) // None for first connection
|
||||
.on_connect(on_connected)
|
||||
.build()
|
||||
.expect("Failed to connect");
|
||||
|
||||
// Subscribe in on_connect callback, NOT before!
|
||||
fn on_connected(conn: &DbConnection, identity: Identity, token: &str) {
|
||||
conn.subscription_builder()
|
||||
.on_applied(|ctx| println!("Ready!"))
|
||||
.subscribe_to_all_tables();
|
||||
}
|
||||
```
|
||||
|
||||
### ⚠️ CRITICAL: Advance the Connection
|
||||
|
||||
**You MUST call one of these** — without it, no callbacks fire:
|
||||
|
||||
```rust
|
||||
conn.run_threaded(); // Background thread (simplest)
|
||||
conn.run_async().await; // Async task
|
||||
conn.frame_tick()?; // Manual polling (game loops)
|
||||
```
|
||||
|
||||
### Table Access & Callbacks
|
||||
|
||||
```rust
|
||||
// Iterate
|
||||
for user in ctx.db.user().iter() { ... }
|
||||
|
||||
// Find by primary key
|
||||
if let Some(user) = ctx.db.user().identity().find(&identity) { ... }
|
||||
|
||||
// Row callbacks
|
||||
ctx.db.user().on_insert(|ctx, user| { ... });
|
||||
ctx.db.user().on_update(|ctx, old, new| { ... });
|
||||
ctx.db.user().on_delete(|ctx, user| { ... });
|
||||
|
||||
// Call reducers
|
||||
ctx.reducers.set_name("Alice".to_string()).unwrap();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8) Procedures (Beta)
|
||||
|
||||
**Procedures are for side effects (HTTP, filesystem) that reducers can't do.**
|
||||
|
||||
⚠️ Procedures are currently in beta. API may change.
|
||||
|
||||
```rust
|
||||
use spacetimedb::{procedure, ProcedureContext};
|
||||
|
||||
// Simple procedure
|
||||
#[procedure]
|
||||
fn add_numbers(_ctx: &mut ProcedureContext, a: u32, b: u32) -> u64 {
|
||||
a as u64 + b as u64
|
||||
}
|
||||
|
||||
// Procedure with database access
|
||||
#[procedure]
|
||||
fn save_external_data(ctx: &mut ProcedureContext, url: String) -> Result<(), String> {
|
||||
// HTTP request (allowed in procedures, not reducers)
|
||||
let data = fetch_from_url(&url)?;
|
||||
|
||||
// Database access requires explicit transaction
|
||||
ctx.try_with_tx(|tx| {
|
||||
tx.db.external_data().insert(ExternalData {
|
||||
id: 0,
|
||||
content: data,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Key differences from reducers
|
||||
|
||||
| Reducers | Procedures |
|
||||
|----------|------------|
|
||||
| `&ReducerContext` (immutable) | `&mut ProcedureContext` (mutable) |
|
||||
| Direct `ctx.db` access | Must use `ctx.with_tx()` |
|
||||
| No HTTP/network | HTTP allowed |
|
||||
| No return values | Can return data |
|
||||
|
||||
---
|
||||
|
||||
## 9) Logging
|
||||
|
||||
```rust
|
||||
use spacetimedb::log;
|
||||
|
||||
log::trace!("Detailed trace");
|
||||
log::debug!("Debug info");
|
||||
log::info!("Information");
|
||||
log::warn!("Warning");
|
||||
log::error!("Error occurred");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10) Commands
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
spacetime start
|
||||
|
||||
# Publish module
|
||||
spacetime publish <module-name> --module-path <backend-dir>
|
||||
|
||||
# Clear database and republish
|
||||
spacetime publish <module-name> --clear-database -y --module-path <backend-dir>
|
||||
|
||||
# Generate bindings
|
||||
spacetime generate --lang rust --out-dir <client>/src/module_bindings --module-path <backend-dir>
|
||||
|
||||
# View logs
|
||||
spacetime logs <module-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11) Hard Requirements
|
||||
|
||||
**Rust-specific:**
|
||||
|
||||
1. **DO NOT derive `SpacetimeType` on `#[table]` structs** — the macro handles this
|
||||
2. **Import `Table` trait** — `use spacetimedb::Table;` required for `.insert()`, `.iter()`, etc.
|
||||
3. **Use `&ReducerContext`** — not `&mut ReducerContext`
|
||||
4. **Tables are methods** — `ctx.db.table()` not `ctx.db.table`
|
||||
5. **Server modules use `spacetimedb` crate** — clients use `spacetimedb-sdk`
|
||||
6. **Reducers must be deterministic** — no filesystem, network, timers, or external RNG
|
||||
7. **Use `ctx.rng`** — not `rand` crate for random numbers
|
||||
8. **Use `ctx.timestamp`** — never `std::time::SystemTime::now()` in reducers
|
||||
9. **Client MUST advance connection** — call `run_threaded()`, `run_async()`, or `frame_tick()`
|
||||
10. **Subscribe in `on_connect` callback** — not before connection is established
|
||||
11. **Update requires full row** — spread existing row with `..existing`
|
||||
12. **DO NOT edit generated bindings** — regenerate with `spacetime generate`
|
||||
13. **Identity to String needs `.to_string()`** — `identity.to_hex().to_string()`
|
||||
14. **Client SDK is blocking** — use `spawn_blocking` or dedicated thread if mixing with async runtimes
|
||||
196
docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md
Normal file
196
docs/technical/SPACETIMEDB_SERVER_NODE_MIGRATION_2026-04-18.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# SpacetimeDB Server-Node 迁移实现说明
|
||||
|
||||
## 本次落地范围
|
||||
|
||||
本次已经把 `server-node` 里与运行时存档、用户认证状态、自定义世界资料库相关的数据库层迁移到 Rust SpacetimeDB 模块中,模块代码位于 `spacetimedb/src/`,按职责拆成以下文件:
|
||||
|
||||
- `lib.rs`
|
||||
- 只保留模块入口和生命周期 reducer。
|
||||
- `types.rs`
|
||||
- 统一放表结构、枚举、view/procedure 的输入输出类型。
|
||||
- `config.rs`
|
||||
- `app_config` 单例表默认值、读取与客户端配置 view。
|
||||
- `auth.rs`
|
||||
- 游客建档、JWT/Identity 建档、短信验证、验证提示事件、踢下线事件、认证审计。
|
||||
- `runtime.rs`
|
||||
- 快照、运行时设置、资料库、自定义世界会话、浏览历史、资料统计与相关 view/procedure。
|
||||
|
||||
## 当前模块设计
|
||||
|
||||
### 1. `app_config` 单例表
|
||||
|
||||
原先散落在 Node `AppConfig` 中、且与认证/验证流程相关的配置,已经迁移到 `app_config` 单例表。
|
||||
|
||||
当前策略:
|
||||
|
||||
- `init` reducer 会自动插入一行默认配置,主键固定为 `id = 1`
|
||||
- 本地环境通过 `spacetime sql` 更新这一行
|
||||
- 客户端只通过 `client_app_config` view 读取必要的公开配置,不直接读整张表
|
||||
|
||||
### 2. 默认游客登录
|
||||
|
||||
连接进入模块时:
|
||||
|
||||
- `client_connected` 会按 `ctx.sender` 自动建档
|
||||
- 无 JWT 时默认为 `guest`
|
||||
- 有 JWT 时使用 SpacetimeDB 自带 `sender_auth().jwt()` 建档,`login_provider` 标记为 `jwt`
|
||||
- 用户主键按 `user_<identity_hex>` 生成,避免再依赖原先 Node 自签 access token / refresh token 流程
|
||||
|
||||
### 3. 短信验证门禁
|
||||
|
||||
当前行为已经按你的要求落地:
|
||||
|
||||
- 是否需要短信验证由 `app_config.sms_verification_required` 决定
|
||||
- 除短信发送 / 短信校验 procedure 外,其余 runtime procedure 统一走门禁
|
||||
- 若用户未完成短信验证:
|
||||
- 先发 `verification_prompt_event`
|
||||
- 再发 `kick_event`
|
||||
- procedure 直接返回 `kicked = true`
|
||||
|
||||
### 4. 登录即弹验证窗
|
||||
|
||||
`client_connected` 中新增了这条行为:
|
||||
|
||||
- 如果命中“已存在用户,且未完成短信验证”
|
||||
- 立即发 `verification_prompt_event`
|
||||
- 前端订阅到事件后即可弹出手机号验证窗口
|
||||
|
||||
### 5. 客户端同步方式
|
||||
|
||||
遵循“尽量不公开表”的要求,当前数据同步面以 `view` 为主:
|
||||
|
||||
- `client_app_config`
|
||||
- `my_auth_state`
|
||||
- `my_auth_audit_logs`
|
||||
- `my_user_sessions`
|
||||
- `my_auth_risk_blocks`
|
||||
- `my_snapshot`
|
||||
- `my_runtime_settings`
|
||||
- `my_profile_dashboard`
|
||||
- `my_profile_wallet_ledger`
|
||||
- `my_profile_played_worlds`
|
||||
- `my_browse_history`
|
||||
- `my_custom_world_profiles`
|
||||
- `my_custom_world_sessions`
|
||||
- `published_custom_world_gallery`
|
||||
|
||||
当前保留为 `public event table` 的只有两类事件:
|
||||
|
||||
- `verification_prompt_event`
|
||||
- `kick_event`
|
||||
|
||||
这是因为客户端要订阅并即时响应事件,而 event table 不能被 view 读取。
|
||||
|
||||
### 6. 账户弹窗同步面
|
||||
|
||||
为了承接客户端账户弹窗,本轮补了:
|
||||
|
||||
- `my_user_sessions`
|
||||
- 用于读取当前账号关联的会话列表
|
||||
- `my_auth_risk_blocks`
|
||||
- 用于读取当前账号手机号/IP 对应的保护记录
|
||||
- `lift_my_risk_block`
|
||||
- 用于当前账号自助解除手机号或当前连接 IP 的保护
|
||||
- `revoke_user_session`
|
||||
- 用于撤销指定会话,并通过事件通知目标连接断开
|
||||
- `logout_all_user_sessions`
|
||||
- 用于撤销当前账号全部会话,并广播撤销事件
|
||||
- `session_revocation_event`
|
||||
- 用于通知目标连接当前 session 已失效
|
||||
|
||||
说明:
|
||||
|
||||
- 当前 `session` 已按连接维度追踪,不再只是 identity 级别的审计记录
|
||||
- 被撤销的连接在再次调用受保护 procedure 时会被踢下线
|
||||
- 前端也会订阅 `session_revocation_event`,在目标 session 被撤销时主动断开当前连接
|
||||
|
||||
## 本地初始化
|
||||
|
||||
### 1. 构建 / 发布本地模块
|
||||
|
||||
```bash
|
||||
spacetime start
|
||||
spacetime publish genarrative-local --clear-database -y --module-path ./spacetimedb
|
||||
```
|
||||
|
||||
### 2. 初始化单例配置
|
||||
|
||||
初始化 SQL 已放到:
|
||||
|
||||
- `scripts/spacetime/init_local_app_config.sql`
|
||||
|
||||
执行方式:
|
||||
|
||||
```bash
|
||||
spacetime sql genarrative-local "$(tr '\n' ' ' < scripts/spacetime/init_local_app_config.sql)"
|
||||
```
|
||||
|
||||
如果你不想走命令替换,也可以直接把 SQL 内容整段复制到 `spacetime sql genarrative-local "<SQL>"` 里执行。
|
||||
|
||||
## 当前已验证状态
|
||||
|
||||
已完成:
|
||||
|
||||
- `cargo check`
|
||||
- `spacetime build`
|
||||
|
||||
说明:
|
||||
|
||||
- 当前模块可以通过 Rust 编译和 Spacetime 模块构建
|
||||
- `spacetime.json` 已切到 TypeScript 绑定输出目录 `src/spacetime/generated`
|
||||
- 前端已开始接入 Spacetime 连接与绑定生成代码
|
||||
|
||||
## 当前客户端改造
|
||||
|
||||
本轮已经把前端里最核心的账号与运行时存储链路切到 Spacetime:
|
||||
|
||||
- `src/spacetime/client.ts`
|
||||
- 统一维护浏览器端 Spacetime 连接、订阅和事件桥。
|
||||
- `src/spacetime/mappers.ts`
|
||||
- 负责把生成绑定里的 view/event 数据映射回现有前端契约类型。
|
||||
- `src/services/authService.ts`
|
||||
- 已从 `/api/auth/*` 切到 Spacetime 连接、view、procedure。
|
||||
- `src/services/storageService.ts`
|
||||
- 已从 `/api/runtime/*` 的存档/设置/资料库接口切到 Spacetime。
|
||||
- `src/services/authService.ts`
|
||||
- 现在也会读取 `my_user_sessions` / `my_auth_risk_blocks`,并调用 `lift_my_risk_block`。
|
||||
- `src/components/auth/AuthGate.tsx`
|
||||
- 已改成默认游客建连,并监听 `verification_prompt_event` / `kick_event`。
|
||||
- `src/components/auth/PhoneVerificationModal.tsx`
|
||||
- 新增短信验证弹窗,收到验证提示事件后直接弹出。
|
||||
|
||||
### 当前客户端行为
|
||||
|
||||
- 页面启动后会直接连到 SpacetimeDB,并复用/写回 Spacetime token
|
||||
- 若账号未完成短信验证:
|
||||
- 连接阶段收到 `verification_prompt_event` 会弹出验证窗
|
||||
- 调用受保护 procedure 后收到 `kick_event` 也会重新弹出验证窗
|
||||
- 存档、设置、个人看板、浏览历史、自定义世界资料库与作品广场已经改走 Spacetime
|
||||
- 账户弹窗里的“当前安全状态 / 会话列表”已经开始读取真实 STDB view
|
||||
- 账户弹窗里的“移除设备 / 全部退出”已经开始调用真实 STDB procedure
|
||||
- 作品广场详情为支持客户端恢复完整 profile,新补了 `published_custom_world_profiles` view
|
||||
|
||||
### 当前仍保留的旧链路
|
||||
|
||||
- `/api/runtime/story/*` 相关故事运行时接口
|
||||
- AI / 资源生成相关 Express 路由
|
||||
- 账号弹窗中的“解除保护 / 移除设备 / 全部退出”都已经走真实 STDB procedure
|
||||
- `runtimeStoryService.ts` 这条故事运行时链路仍未迁移,还是当前最大的遗留
|
||||
|
||||
### 前端环境变量
|
||||
|
||||
请在本地配置:
|
||||
|
||||
- `VITE_SPACETIME_URI`
|
||||
- `VITE_SPACETIME_DATABASE_NAME`
|
||||
|
||||
示例已补到 `.env.example`。
|
||||
|
||||
## 后续建议
|
||||
|
||||
下一步如果继续推进,建议按这个顺序:
|
||||
|
||||
1. 前端接入 `verification_prompt_event` / `kick_event`
|
||||
2. 补齐账号弹窗需要的 session / risk block view 与对应操作 procedure
|
||||
3. 把原 `/api/runtime/story/*` 运行时动作接口继续向 Spacetime 迁移
|
||||
4. 再决定是否保留 `server-node` 作为纯 HTTP/AI 代理层
|
||||
146
package-lock.json
generated
146
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"spacetimedb": "^2.1.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2289,6 +2290,26 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.9",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
|
||||
@@ -3685,6 +3706,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/headers-polyfill": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
|
||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
@@ -4761,7 +4788,6 @@
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4831,6 +4857,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||
"integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/dubzzz"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fast-check"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -5067,6 +5109,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -5264,6 +5315,50 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spacetimedb": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.1.0.tgz",
|
||||
"integrity": "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"headers-polyfill": "^4.0.3",
|
||||
"object-inspect": "^1.13.4",
|
||||
"prettier": "^3.3.3",
|
||||
"pure-rand": "^7.0.1",
|
||||
"safe-stable-stringify": "^2.5.0",
|
||||
"statuses": "^2.0.2",
|
||||
"url-polyfill": "^1.1.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": ">=17.0.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0-0 || ^19.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0",
|
||||
"undici": "^6.19.2",
|
||||
"vue": "^3.3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@tanstack/react-query": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"undici": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -5618,6 +5713,12 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -8704,6 +8805,11 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||
},
|
||||
"baseline-browser-mapping": {
|
||||
"version": "2.10.9",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
|
||||
@@ -9698,6 +9804,11 @@
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"headers-polyfill": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz",
|
||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
||||
},
|
||||
"html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
@@ -10387,8 +10498,7 @@
|
||||
"prettier": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
|
||||
"dev": true
|
||||
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew=="
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "29.7.0",
|
||||
@@ -10433,6 +10543,11 @@
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true
|
||||
},
|
||||
"pure-rand": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz",
|
||||
"integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="
|
||||
},
|
||||
"qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -10580,6 +10695,11 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
@@ -10733,6 +10853,21 @@
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||
},
|
||||
"spacetimedb": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.1.0.tgz",
|
||||
"integrity": "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==",
|
||||
"requires": {
|
||||
"base64-js": "^1.5.1",
|
||||
"headers-polyfill": "^4.0.3",
|
||||
"object-inspect": "^1.13.4",
|
||||
"prettier": "^3.3.3",
|
||||
"pure-rand": "^7.0.1",
|
||||
"safe-stable-stringify": "^2.5.0",
|
||||
"statuses": "^2.0.2",
|
||||
"url-polyfill": "^1.1.14"
|
||||
}
|
||||
},
|
||||
"stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -10977,6 +11112,11 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"url-polyfill": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="
|
||||
},
|
||||
"utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"spacetimedb": "^2.1.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type AuthBindingStatus = 'active' | 'pending_bind_phone';
|
||||
export type AuthLoginMethod = 'password' | 'phone' | 'wechat';
|
||||
export type AuthLoginMethod = 'guest' | 'jwt' | 'password' | 'phone' | 'wechat';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
|
||||
50
scripts/spacetime/init_local_app_config.sql
Normal file
50
scripts/spacetime/init_local_app_config.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
UPDATE app_config
|
||||
SET guest_login_enabled = TRUE,
|
||||
sms_auth_enabled = TRUE,
|
||||
sms_verification_required = TRUE,
|
||||
sms_provider = 'mock',
|
||||
sms_endpoint = 'dypnsapi.aliyuncs.com',
|
||||
sms_access_key_id = '',
|
||||
sms_access_key_secret = '',
|
||||
sms_sign_name = '',
|
||||
sms_template_code = '',
|
||||
sms_template_param_key = 'code',
|
||||
sms_country_code = '86',
|
||||
sms_scheme_name = '',
|
||||
sms_code_length = 6,
|
||||
sms_code_type = 1,
|
||||
sms_valid_time_seconds = 300,
|
||||
sms_interval_seconds = 60,
|
||||
sms_duplicate_policy = 1,
|
||||
sms_case_auth_policy = 1,
|
||||
sms_return_verify_code = FALSE,
|
||||
sms_mock_verify_code = '123456',
|
||||
sms_max_send_per_phone_per_day = 20,
|
||||
sms_max_send_per_ip_per_hour = 30,
|
||||
sms_max_verify_failures_per_phone_per_hour = 12,
|
||||
sms_max_verify_failures_per_ip_per_hour = 24,
|
||||
sms_captcha_ttl_seconds = 180,
|
||||
sms_captcha_trigger_verify_failures_per_phone = 3,
|
||||
sms_captcha_trigger_verify_failures_per_ip = 5,
|
||||
sms_block_phone_failure_threshold = 6,
|
||||
sms_block_ip_failure_threshold = 10,
|
||||
sms_block_phone_duration_minutes = 30,
|
||||
sms_block_ip_duration_minutes = 30,
|
||||
default_music_volume = 0.42,
|
||||
default_guest_display_name_prefix = '游客',
|
||||
kick_message_unverified = '账号尚未完成短信验证,请先完成验证',
|
||||
wechat_enabled = FALSE,
|
||||
wechat_provider = 'mock',
|
||||
wechat_app_id = '',
|
||||
wechat_app_secret = '',
|
||||
wechat_authorize_endpoint = 'https://open.weixin.qq.com/connect/qrconnect',
|
||||
wechat_access_token_endpoint = 'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||||
wechat_user_info_endpoint = 'https://api.weixin.qq.com/sns/userinfo',
|
||||
wechat_callback_path = '/api/auth/wechat/callback',
|
||||
wechat_default_redirect_path = '/',
|
||||
wechat_mock_user_id = 'mock_wechat_user',
|
||||
wechat_mock_union_id = 'mock_wechat_union',
|
||||
wechat_mock_display_name = '微信旅人',
|
||||
wechat_mock_avatar_url = '',
|
||||
updated_at_ms = 1744934400000
|
||||
WHERE id = 1;
|
||||
10
spacetime.json
Normal file
10
spacetime.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"server": "maincloud",
|
||||
"module-path": "./spacetimedb",
|
||||
"generate": [
|
||||
{
|
||||
"language": "typescript",
|
||||
"out-dir": "./src/spacetime/generated"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
spacetime.local.json
Normal file
3
spacetime.local.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"database": "xushi-p4wfr"
|
||||
}
|
||||
1
spacetimedb/.gitignore
vendored
Normal file
1
spacetimedb/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
996
spacetimedb/Cargo.lock
generated
Normal file
996
spacetimedb/Cargo.lock
generated
Normal file
@@ -0,0 +1,996 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0e60b75072ecd4168020818c0107f2857bb6c4e64252d8d3983f6263b40a5c3"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "castaway"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.60"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decorum"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "281759d3c8a14f5c3f0c49363be56810fcd7f910422f97f2db850c2920fde5cf"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "ethnum"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lean_string"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a262b6ae1dd9c2d3cf7977a816578b03bf8fb60b61545c395880f95eefc5b24"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"itoa",
|
||||
"ryu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "second-stack"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4904c83c6e51f1b9b08bfa5a86f35a51798e8307186e6f5513852210a219c0bb"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "591f9068644aab6808e7612a869dedde7eeb26df78027a19bc9dc597cc649678"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"derive_more",
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
"log",
|
||||
"rand 0.8.6",
|
||||
"scoped-tls",
|
||||
"serde_json",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-bindings-sys",
|
||||
"spacetimedb-lib",
|
||||
"spacetimedb-primitives",
|
||||
"spacetimedb-query-builder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-bindings-macro"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f68bf4810d838be622c13efd4cd64e0a9ce8cd340deaa730f0c92caee845f9"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"humantime",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"spacetimedb-primitives",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-bindings-sys"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c2fe9f4124a599c9deae8f8231be3ae5a49bc5b2eef5e04c04b2632cf4cc0b4"
|
||||
dependencies = [
|
||||
"spacetimedb-primitives",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-lib"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "672c0dd16feced67155a0dee7bd38d30f7725321c8177cb871a21c3d8749ae97"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"derive_more",
|
||||
"enum-as-inner",
|
||||
"hex",
|
||||
"itertools",
|
||||
"log",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-primitives",
|
||||
"spacetimedb-sats",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-primitives"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dba5d7497d54aa8d4254f78a0bef12606bb05e62f8dea8b69abc9b241508e8b7"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"either",
|
||||
"enum-as-inner",
|
||||
"itertools",
|
||||
"nohash-hasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-query-builder"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d04c6e41e05273f14405ac6f429477626677d46528b561a509b7b78b45128f30"
|
||||
dependencies = [
|
||||
"spacetimedb-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spacetimedb-sats"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfde33ec86d80881da8b00c42096bf0382bef8e1bc35e9b6faaa42d77cbf503c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"decorum",
|
||||
"derive_more",
|
||||
"enum-as-inner",
|
||||
"ethnum",
|
||||
"hex",
|
||||
"itertools",
|
||||
"lean_string",
|
||||
"rand 0.9.4",
|
||||
"second-stack",
|
||||
"sha3",
|
||||
"smallvec",
|
||||
"spacetimedb-bindings-macro",
|
||||
"spacetimedb-primitives",
|
||||
"thiserror",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xushi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
14
spacetimedb/Cargo.toml
Normal file
14
spacetimedb/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "xushi"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
spacetimedb = { version = "2.1.0", features = ["unstable"] }
|
||||
log = "0.4"
|
||||
serde_json = "1.0"
|
||||
1174
spacetimedb/src/auth.rs
Normal file
1174
spacetimedb/src/auth.rs
Normal file
File diff suppressed because it is too large
Load Diff
302
spacetimedb/src/common.rs
Normal file
302
spacetimedb/src/common.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use std::cmp::Reverse;
|
||||
|
||||
use serde_json::Value;
|
||||
use spacetimedb::{JwtClaims, Timestamp};
|
||||
|
||||
use crate::types::{PlatformBrowseHistoryWriteInput, RequestMeta};
|
||||
|
||||
pub const APP_CONFIG_SINGLETON_ID: u8 = 1;
|
||||
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
|
||||
pub const MAX_CUSTOM_WORLD_PROFILES: usize = 12;
|
||||
pub const MAX_PUBLIC_CUSTOM_WORLD_PROFILES: usize = 36;
|
||||
pub const MAX_WALLET_LEDGER_ENTRIES: usize = 50;
|
||||
pub const MAX_AUTH_AUDIT_LOGS: usize = 20;
|
||||
|
||||
pub fn timestamp_ms(timestamp: Timestamp) -> u64 {
|
||||
let millis = timestamp
|
||||
.to_duration_since_unix_epoch()
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
u64::try_from(millis).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
pub fn jwt_exp_ms(jwt: &JwtClaims) -> Option<u64> {
|
||||
let value = parse_json(jwt.raw_payload())?;
|
||||
let exp = value.get("exp")?;
|
||||
let numeric = exp
|
||||
.as_u64()
|
||||
.or_else(|| exp.as_i64().and_then(|item| u64::try_from(item).ok()))?;
|
||||
if numeric > 1_000_000_000_000 {
|
||||
Some(numeric)
|
||||
} else {
|
||||
Some(numeric.saturating_mul(1_000))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_required_string(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(normalize_required_string)
|
||||
.filter(|row| !row.is_empty())
|
||||
}
|
||||
|
||||
pub fn normalize_client_type(value: Option<&str>) -> String {
|
||||
normalize_optional_string(value)
|
||||
.filter(|row| !row.is_empty())
|
||||
.unwrap_or_else(|| "spacetime_http".to_string())
|
||||
}
|
||||
|
||||
pub fn normalize_saved_at_ms(saved_at_ms: u64, timestamp: Timestamp) -> u64 {
|
||||
if saved_at_ms == 0 {
|
||||
timestamp_ms(timestamp)
|
||||
} else {
|
||||
saved_at_ms
|
||||
}
|
||||
}
|
||||
|
||||
pub fn user_id_for_identity_hex(identity_hex: &str) -> String {
|
||||
format!("user_{identity_hex}")
|
||||
}
|
||||
|
||||
pub fn session_id_for_identity_hex(identity_hex: &str) -> String {
|
||||
format!("usess_{identity_hex}")
|
||||
}
|
||||
|
||||
pub fn jwt_identity_id(identity_hex: &str) -> String {
|
||||
format!("authi_jwt_{identity_hex}")
|
||||
}
|
||||
|
||||
pub fn guest_identity_id(identity_hex: &str) -> String {
|
||||
format!("authi_guest_{identity_hex}")
|
||||
}
|
||||
|
||||
pub fn phone_identity_id(phone_number: &str) -> String {
|
||||
format!("authi_phone_{}", phone_number.replace('+', ""))
|
||||
}
|
||||
|
||||
pub fn custom_world_profile_key(user_id: &str, profile_id: &str) -> String {
|
||||
format!("{user_id}:{profile_id}")
|
||||
}
|
||||
|
||||
pub fn custom_world_session_key(user_id: &str, session_id: &str) -> String {
|
||||
format!("{user_id}:{session_id}")
|
||||
}
|
||||
|
||||
pub fn profile_wallet_ledger_id(user_id: &str, source_key: &str) -> String {
|
||||
format!("ledger:{user_id}:{source_key}")
|
||||
}
|
||||
|
||||
pub fn profile_played_world_key(user_id: &str, world_key: &str) -> String {
|
||||
format!("played:{user_id}:{world_key}")
|
||||
}
|
||||
|
||||
pub fn browse_history_key(user_id: &str, owner_user_id: &str, profile_id: &str) -> String {
|
||||
format!("browse:{user_id}:{owner_user_id}:{profile_id}")
|
||||
}
|
||||
|
||||
pub fn ip_key(ip: Option<&String>) -> String {
|
||||
ip.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn remaining_seconds(now_ms: u64, expires_at_ms: u64) -> u64 {
|
||||
expires_at_ms.saturating_sub(now_ms) / 1_000
|
||||
}
|
||||
|
||||
pub struct NormalizedPhoneNumber {
|
||||
pub e164: String,
|
||||
}
|
||||
|
||||
pub fn normalize_mainland_china_phone_number(
|
||||
phone_input: &str,
|
||||
) -> Result<NormalizedPhoneNumber, String> {
|
||||
let stripped: String = phone_input
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_digit() || *ch == '+')
|
||||
.collect();
|
||||
if stripped.trim().is_empty() {
|
||||
return Err("请输入手机号".to_string());
|
||||
}
|
||||
|
||||
let mut national_number = stripped.trim().to_string();
|
||||
if national_number.starts_with("+86") {
|
||||
national_number = national_number[3..].to_string();
|
||||
} else if national_number.starts_with("86") && national_number.len() == 13 {
|
||||
national_number = national_number[2..].to_string();
|
||||
}
|
||||
|
||||
let is_valid = national_number.len() == 11
|
||||
&& national_number.starts_with('1')
|
||||
&& national_number.chars().all(|ch| ch.is_ascii_digit());
|
||||
if !is_valid {
|
||||
return Err("请输入正确的中国大陆手机号".to_string());
|
||||
}
|
||||
|
||||
Ok(NormalizedPhoneNumber {
|
||||
e164: format!("+86{national_number}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mask_mainland_phone_number(phone_number: &str) -> Option<String> {
|
||||
let normalized = normalize_mainland_china_phone_number(phone_number).ok()?;
|
||||
let national_number = normalized.e164.trim_start_matches("+86");
|
||||
if national_number.len() < 7 {
|
||||
return Some(national_number.to_string());
|
||||
}
|
||||
Some(format!(
|
||||
"{}****{}",
|
||||
&national_number[..3],
|
||||
&national_number[national_number.len().saturating_sub(4)..]
|
||||
))
|
||||
}
|
||||
|
||||
pub fn validate_sms_verify_code(code: &str) -> Result<String, String> {
|
||||
let normalized = code.trim().to_string();
|
||||
let is_valid = (4..=8).contains(&normalized.len())
|
||||
&& normalized.chars().all(|ch| ch.is_ascii_alphanumeric());
|
||||
if !is_valid {
|
||||
return Err("请输入正确的验证码".to_string());
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn parse_json(input: &str) -> Option<Value> {
|
||||
serde_json::from_str(input).ok()
|
||||
}
|
||||
|
||||
pub fn is_valid_json(input: &str) -> bool {
|
||||
parse_json(input).is_some()
|
||||
}
|
||||
|
||||
pub fn read_i64_field(value: &Value, key: &str) -> i64 {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.or_else(|| {
|
||||
value.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|item| i64::try_from(item).ok())
|
||||
})
|
||||
.or_else(|| {
|
||||
value.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|item| item.parse::<i64>().ok())
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn read_nested_u64(value: &Value, path: &[&str]) -> u64 {
|
||||
let mut cursor = value;
|
||||
for key in path {
|
||||
let Some(next) = cursor.get(*key) else {
|
||||
return 0;
|
||||
};
|
||||
cursor = next;
|
||||
}
|
||||
cursor
|
||||
.as_u64()
|
||||
.or_else(|| cursor.as_i64().and_then(|item| u64::try_from(item).ok()))
|
||||
.or_else(|| cursor.as_str().and_then(|item| item.parse::<u64>().ok()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn read_json_string_from_object(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|row| !row.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn builtin_world_title(world_type: &str) -> String {
|
||||
match world_type {
|
||||
"WUXIA" => "武侠世界".to_string(),
|
||||
"XIANXIA" => "仙侠世界".to_string(),
|
||||
_ => "叙事世界".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_any(source: &str, keywords: &[&str]) -> bool {
|
||||
keywords.iter().any(|keyword| source.contains(keyword))
|
||||
}
|
||||
|
||||
pub fn resolve_author_display_name(candidate: &str, fallback: &str) -> String {
|
||||
let normalized = normalize_required_string(candidate);
|
||||
if normalized.is_empty() {
|
||||
normalize_optional_string(Some(fallback)).unwrap_or_else(|| "玩家".to_string())
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sort_desc_by_key<T, K: Ord>(rows: &mut [T], mut key: impl FnMut(&T) -> K) {
|
||||
rows.sort_by_key(|row| Reverse(key(row)));
|
||||
}
|
||||
|
||||
pub fn dedupe_browse_history_entries(
|
||||
entries: Vec<PlatformBrowseHistoryWriteInput>,
|
||||
now_ms: u64,
|
||||
) -> Vec<PlatformBrowseHistoryWriteInput> {
|
||||
let mut normalized_entries = entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| normalize_browse_history_entry(entry, now_ms))
|
||||
.collect::<Vec<_>>();
|
||||
normalized_entries.sort_by_key(|entry| Reverse(entry.visited_at_ms));
|
||||
|
||||
let mut deduped = Vec::new();
|
||||
let mut seen_keys = std::collections::BTreeSet::new();
|
||||
for entry in normalized_entries {
|
||||
let key = format!("{}:{}", entry.owner_user_id, entry.profile_id);
|
||||
if seen_keys.insert(key) {
|
||||
deduped.push(entry);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
fn normalize_browse_history_entry(
|
||||
entry: PlatformBrowseHistoryWriteInput,
|
||||
now_ms: u64,
|
||||
) -> Option<PlatformBrowseHistoryWriteInput> {
|
||||
let owner_user_id = normalize_required_string(&entry.owner_user_id);
|
||||
let profile_id = normalize_required_string(&entry.profile_id);
|
||||
let world_name = normalize_required_string(&entry.world_name);
|
||||
if owner_user_id.is_empty() || profile_id.is_empty() || world_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PlatformBrowseHistoryWriteInput {
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
world_name,
|
||||
subtitle: normalize_required_string(&entry.subtitle),
|
||||
summary_text: normalize_required_string(&entry.summary_text),
|
||||
cover_image_src: entry
|
||||
.cover_image_src
|
||||
.and_then(|value| normalize_optional_string(Some(&value))),
|
||||
theme_mode: entry.theme_mode,
|
||||
author_display_name: normalize_optional_string(Some(&entry.author_display_name))
|
||||
.unwrap_or_else(|| "玩家".to_string()),
|
||||
visited_at_ms: if entry.visited_at_ms == 0 {
|
||||
now_ms
|
||||
} else {
|
||||
entry.visited_at_ms
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn request_meta_ip(meta: &RequestMeta) -> Option<String> {
|
||||
normalize_optional_string(meta.ip.as_deref())
|
||||
}
|
||||
|
||||
pub fn request_meta_user_agent(meta: &RequestMeta) -> Option<String> {
|
||||
normalize_optional_string(meta.user_agent.as_deref())
|
||||
}
|
||||
107
spacetimedb/src/config.rs
Normal file
107
spacetimedb/src/config.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use spacetimedb::{view, AnonymousViewContext, ReducerContext, Table, TxContext};
|
||||
|
||||
use crate::common::{timestamp_ms, APP_CONFIG_SINGLETON_ID};
|
||||
use crate::types::*;
|
||||
|
||||
pub fn default_app_config(now_ms: u64) -> AppConfig {
|
||||
AppConfig {
|
||||
id: APP_CONFIG_SINGLETON_ID,
|
||||
guest_login_enabled: true,
|
||||
sms_auth_enabled: false,
|
||||
sms_verification_required: false,
|
||||
sms_provider: "mock".to_string(),
|
||||
sms_endpoint: "dypnsapi.aliyuncs.com".to_string(),
|
||||
sms_access_key_id: String::new(),
|
||||
sms_access_key_secret: String::new(),
|
||||
sms_sign_name: String::new(),
|
||||
sms_template_code: String::new(),
|
||||
sms_template_param_key: "code".to_string(),
|
||||
sms_country_code: "86".to_string(),
|
||||
sms_scheme_name: String::new(),
|
||||
sms_code_length: 6,
|
||||
sms_code_type: 1,
|
||||
sms_valid_time_seconds: 300,
|
||||
sms_interval_seconds: 60,
|
||||
sms_duplicate_policy: 1,
|
||||
sms_case_auth_policy: 1,
|
||||
sms_return_verify_code: false,
|
||||
sms_mock_verify_code: "123456".to_string(),
|
||||
sms_max_send_per_phone_per_day: 20,
|
||||
sms_max_send_per_ip_per_hour: 30,
|
||||
sms_max_verify_failures_per_phone_per_hour: 12,
|
||||
sms_max_verify_failures_per_ip_per_hour: 24,
|
||||
sms_captcha_ttl_seconds: 180,
|
||||
sms_captcha_trigger_verify_failures_per_phone: 3,
|
||||
sms_captcha_trigger_verify_failures_per_ip: 5,
|
||||
sms_block_phone_failure_threshold: 6,
|
||||
sms_block_ip_failure_threshold: 10,
|
||||
sms_block_phone_duration_minutes: 30,
|
||||
sms_block_ip_duration_minutes: 30,
|
||||
default_music_volume: 0.42,
|
||||
default_guest_display_name_prefix: "游客".to_string(),
|
||||
kick_message_unverified: "账号尚未完成短信验证,请先完成验证".to_string(),
|
||||
wechat_enabled: false,
|
||||
wechat_provider: "mock".to_string(),
|
||||
wechat_app_id: String::new(),
|
||||
wechat_app_secret: String::new(),
|
||||
wechat_authorize_endpoint: "https://open.weixin.qq.com/connect/qrconnect".to_string(),
|
||||
wechat_access_token_endpoint: "https://api.weixin.qq.com/sns/oauth2/access_token".to_string(),
|
||||
wechat_user_info_endpoint: "https://api.weixin.qq.com/sns/userinfo".to_string(),
|
||||
wechat_callback_path: "/api/auth/wechat/callback".to_string(),
|
||||
wechat_default_redirect_path: "/".to_string(),
|
||||
wechat_mock_user_id: "mock_wechat_user".to_string(),
|
||||
wechat_mock_union_id: "mock_wechat_union".to_string(),
|
||||
wechat_mock_display_name: "微信旅人".to_string(),
|
||||
wechat_mock_avatar_url: String::new(),
|
||||
updated_at_ms: now_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_default_app_config(ctx: &ReducerContext) {
|
||||
if ctx.db.app_config().id().find(&APP_CONFIG_SINGLETON_ID).is_none() {
|
||||
ctx.db
|
||||
.app_config()
|
||||
.insert(default_app_config(timestamp_ms(ctx.timestamp)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_app_config_row(tx: &TxContext) -> AppConfig {
|
||||
tx.db
|
||||
.app_config()
|
||||
.id()
|
||||
.find(&APP_CONFIG_SINGLETON_ID)
|
||||
.unwrap_or_else(|| {
|
||||
let config = default_app_config(timestamp_ms(tx.timestamp));
|
||||
tx.db.app_config().insert(config.clone());
|
||||
config
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_app_config_read_only() -> Option<AppConfig> {
|
||||
AnonymousViewContext::default()
|
||||
.db
|
||||
.app_config()
|
||||
.id()
|
||||
.find(&APP_CONFIG_SINGLETON_ID)
|
||||
}
|
||||
|
||||
pub fn to_client_app_config_view(config: &AppConfig) -> ClientAppConfigView {
|
||||
ClientAppConfigView {
|
||||
guest_login_enabled: config.guest_login_enabled,
|
||||
sms_auth_enabled: config.sms_auth_enabled,
|
||||
sms_verification_required: config.sms_verification_required,
|
||||
sms_provider: config.sms_provider.clone(),
|
||||
sms_code_length: config.sms_code_length,
|
||||
sms_valid_time_seconds: config.sms_valid_time_seconds,
|
||||
sms_interval_seconds: config.sms_interval_seconds,
|
||||
default_music_volume: config.default_music_volume,
|
||||
default_guest_display_name_prefix: config.default_guest_display_name_prefix.clone(),
|
||||
wechat_enabled: config.wechat_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
#[view(accessor = client_app_config, public)]
|
||||
pub fn client_app_config_view(_ctx: &AnonymousViewContext) -> Option<ClientAppConfigView> {
|
||||
let config = load_app_config_read_only().unwrap_or_else(|| default_app_config(0));
|
||||
Some(to_client_app_config_view(&config))
|
||||
}
|
||||
26
spacetimedb/src/lib.rs
Normal file
26
spacetimedb/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
mod auth;
|
||||
mod common;
|
||||
mod config;
|
||||
mod runtime;
|
||||
mod types;
|
||||
|
||||
use spacetimedb::{reducer, ReducerContext};
|
||||
|
||||
#[reducer(init)]
|
||||
pub fn init(ctx: &ReducerContext) {
|
||||
config::ensure_default_app_config(ctx);
|
||||
log::info!("genarrative stdb init completed");
|
||||
}
|
||||
|
||||
#[reducer(client_connected)]
|
||||
pub fn client_connected(ctx: &ReducerContext) {
|
||||
config::ensure_default_app_config(ctx);
|
||||
let provisioned = auth::provision_user(ctx);
|
||||
if provisioned.existed && auth::needs_sms_verification(&provisioned.config, &provisioned.user)
|
||||
{
|
||||
auth::emit_verification_prompt(ctx, &provisioned.user, "账号尚未完成短信验证,请先验证手机号");
|
||||
}
|
||||
}
|
||||
|
||||
#[reducer(client_disconnected)]
|
||||
pub fn client_disconnected(_ctx: &ReducerContext) {}
|
||||
1034
spacetimedb/src/runtime.rs
Normal file
1034
spacetimedb/src/runtime.rs
Normal file
File diff suppressed because it is too large
Load Diff
665
spacetimedb/src/types.rs
Normal file
665
spacetimedb/src/types.rs
Normal file
@@ -0,0 +1,665 @@
|
||||
use spacetimedb::{table, Identity, SpacetimeType};
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LoginProvider {
|
||||
Guest,
|
||||
Jwt,
|
||||
Phone,
|
||||
Wechat,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
PendingSmsVerification,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AuthIdentityProvider {
|
||||
Guest,
|
||||
Jwt,
|
||||
Phone,
|
||||
Wechat,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SmsAuthScene {
|
||||
Login,
|
||||
BindPhone,
|
||||
ChangePhone,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SmsAuthAction {
|
||||
SendCode,
|
||||
VerifyCode,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RiskBlockScopeType {
|
||||
Phone,
|
||||
Ip,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CustomWorldPublicationStatus {
|
||||
Draft,
|
||||
Published,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CustomWorldThemeMode {
|
||||
Martial,
|
||||
Arcane,
|
||||
Machina,
|
||||
Tide,
|
||||
Rift,
|
||||
Mythic,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct RequestMeta {
|
||||
pub client_type: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct PlatformBrowseHistoryWriteInput {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct MutationResult {
|
||||
pub ok: bool,
|
||||
pub kicked: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl MutationResult {
|
||||
pub fn ok(code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
kicked: false,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
kicked: false,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kicked(code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
kicked: true,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct SmsSendCodeResult {
|
||||
pub ok: bool,
|
||||
pub kicked: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub cooldown_seconds: u32,
|
||||
pub expires_in_seconds: u32,
|
||||
pub provider_request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SmsSendCodeResult {
|
||||
pub fn ok(code: &str, message: &str, cooldown_seconds: u32, expires_in_seconds: u32) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
kicked: false,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
cooldown_seconds,
|
||||
expires_in_seconds,
|
||||
provider_request_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
kicked: false,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
cooldown_seconds: 0,
|
||||
expires_in_seconds: 0,
|
||||
provider_request_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct SmsVerifyCodeResult {
|
||||
pub ok: bool,
|
||||
pub kicked: bool,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl SmsVerifyCodeResult {
|
||||
pub fn ok(message: &str) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
kicked: false,
|
||||
code: "sms_verified".to_string(),
|
||||
message: message.to_string(),
|
||||
verified: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(code: &str, message: &str) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
kicked: false,
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
verified: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct ClientAppConfigView {
|
||||
pub guest_login_enabled: bool,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub sms_verification_required: bool,
|
||||
pub sms_provider: String,
|
||||
pub sms_code_length: u16,
|
||||
pub sms_valid_time_seconds: u32,
|
||||
pub sms_interval_seconds: u32,
|
||||
pub default_music_volume: f32,
|
||||
pub default_guest_display_name_prefix: String,
|
||||
pub wechat_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct AuthStateView {
|
||||
pub user_id: String,
|
||||
pub identity: Identity,
|
||||
pub display_name: String,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub login_provider: LoginProvider,
|
||||
pub account_status: AccountStatus,
|
||||
pub sms_verification_required: bool,
|
||||
pub sms_verified: bool,
|
||||
pub jwt_present: bool,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct SnapshotView {
|
||||
pub version: u32,
|
||||
pub saved_at_ms: u64,
|
||||
pub game_state_json: String,
|
||||
pub bottom_tab: String,
|
||||
pub current_story_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub music_volume: f32,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct ProfileDashboardView {
|
||||
pub wallet_balance: i64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub played_world_count: u32,
|
||||
pub updated_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct ProfileWalletLedgerView {
|
||||
pub id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: i64,
|
||||
pub source_type: String,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct ProfilePlayedWorldView {
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at_ms: u64,
|
||||
pub last_played_at_ms: u64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct PlatformBrowseHistoryView {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct CustomWorldProfileView {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub payload_json: String,
|
||||
pub visibility: CustomWorldPublicationStatus,
|
||||
pub published_at_ms: Option<u64>,
|
||||
pub updated_at_ms: u64,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct CustomWorldGalleryCardView {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub visibility: CustomWorldPublicationStatus,
|
||||
pub published_at_ms: Option<u64>,
|
||||
pub updated_at_ms: u64,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct PublishedCustomWorldProfileView {
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub payload_json: String,
|
||||
pub visibility: CustomWorldPublicationStatus,
|
||||
pub published_at_ms: Option<u64>,
|
||||
pub updated_at_ms: u64,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct CustomWorldSessionView {
|
||||
pub session_id: String,
|
||||
pub payload_json: String,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct AuthAuditLogView {
|
||||
pub id: u64,
|
||||
pub event_type: String,
|
||||
pub detail: String,
|
||||
pub ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct AuthSessionView {
|
||||
pub session_id: String,
|
||||
pub client_type: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub is_current: bool,
|
||||
pub created_at_ms: u64,
|
||||
pub last_seen_at_ms: u64,
|
||||
pub expires_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(SpacetimeType, Clone, Debug)]
|
||||
pub struct AuthRiskBlockView {
|
||||
pub scope_type: RiskBlockScopeType,
|
||||
pub scope_key: String,
|
||||
pub reason: String,
|
||||
pub expires_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = app_config)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
#[primary_key]
|
||||
pub id: u8,
|
||||
pub guest_login_enabled: bool,
|
||||
pub sms_auth_enabled: bool,
|
||||
pub sms_verification_required: bool,
|
||||
pub sms_provider: String,
|
||||
pub sms_endpoint: String,
|
||||
pub sms_access_key_id: String,
|
||||
pub sms_access_key_secret: String,
|
||||
pub sms_sign_name: String,
|
||||
pub sms_template_code: String,
|
||||
pub sms_template_param_key: String,
|
||||
pub sms_country_code: String,
|
||||
pub sms_scheme_name: String,
|
||||
pub sms_code_length: u16,
|
||||
pub sms_code_type: u16,
|
||||
pub sms_valid_time_seconds: u32,
|
||||
pub sms_interval_seconds: u32,
|
||||
pub sms_duplicate_policy: u16,
|
||||
pub sms_case_auth_policy: u16,
|
||||
pub sms_return_verify_code: bool,
|
||||
pub sms_mock_verify_code: String,
|
||||
pub sms_max_send_per_phone_per_day: u16,
|
||||
pub sms_max_send_per_ip_per_hour: u16,
|
||||
pub sms_max_verify_failures_per_phone_per_hour: u16,
|
||||
pub sms_max_verify_failures_per_ip_per_hour: u16,
|
||||
pub sms_captcha_ttl_seconds: u32,
|
||||
pub sms_captcha_trigger_verify_failures_per_phone: u16,
|
||||
pub sms_captcha_trigger_verify_failures_per_ip: u16,
|
||||
pub sms_block_phone_failure_threshold: u16,
|
||||
pub sms_block_ip_failure_threshold: u16,
|
||||
pub sms_block_phone_duration_minutes: u16,
|
||||
pub sms_block_ip_duration_minutes: u16,
|
||||
pub default_music_volume: f32,
|
||||
pub default_guest_display_name_prefix: String,
|
||||
pub kick_message_unverified: String,
|
||||
pub wechat_enabled: bool,
|
||||
pub wechat_provider: String,
|
||||
pub wechat_app_id: String,
|
||||
pub wechat_app_secret: String,
|
||||
pub wechat_authorize_endpoint: String,
|
||||
pub wechat_access_token_endpoint: String,
|
||||
pub wechat_user_info_endpoint: String,
|
||||
pub wechat_callback_path: String,
|
||||
pub wechat_default_redirect_path: String,
|
||||
pub wechat_mock_user_id: String,
|
||||
pub wechat_mock_union_id: String,
|
||||
pub wechat_mock_display_name: String,
|
||||
pub wechat_mock_avatar_url: String,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = user)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[unique]
|
||||
pub identity: Identity,
|
||||
pub username: Option<String>,
|
||||
pub password_hash: Option<String>,
|
||||
pub token_version: u32,
|
||||
pub display_name: String,
|
||||
pub login_provider: LoginProvider,
|
||||
pub account_status: AccountStatus,
|
||||
pub phone_number: Option<String>,
|
||||
pub phone_verified_at_ms: Option<u64>,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = auth_identity)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthIdentity {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub provider: AuthIdentityProvider,
|
||||
pub provider_uid: String,
|
||||
pub provider_union_id: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub is_verified: bool,
|
||||
pub meta_json: Option<String>,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = user_session)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UserSession {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub client_type: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub expires_at_ms: Option<u64>,
|
||||
pub revoked_at_ms: Option<u64>,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
pub last_seen_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = auth_audit_log)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthAuditLog {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub event_type: String,
|
||||
pub detail: String,
|
||||
pub ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub meta_json: Option<String>,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = sms_auth_event)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SmsAuthEvent {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
#[index(btree)]
|
||||
pub identity: Identity,
|
||||
#[index(btree)]
|
||||
pub phone_number: String,
|
||||
pub scene: SmsAuthScene,
|
||||
pub action: SmsAuthAction,
|
||||
pub success: bool,
|
||||
pub ip: Option<String>,
|
||||
#[index(btree)]
|
||||
pub ip_key: String,
|
||||
pub user_agent: Option<String>,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = auth_risk_block)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AuthRiskBlock {
|
||||
#[primary_key]
|
||||
#[auto_inc]
|
||||
pub id: u64,
|
||||
#[index(btree)]
|
||||
pub scope_type: RiskBlockScopeType,
|
||||
pub scope_key: String,
|
||||
pub reason: String,
|
||||
pub expires_at_ms: u64,
|
||||
pub lifted_at_ms: Option<u64>,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = saved_snapshot_row)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SaveSnapshot {
|
||||
#[primary_key]
|
||||
pub user_id: String,
|
||||
pub version: u32,
|
||||
pub saved_at_ms: u64,
|
||||
pub bottom_tab: String,
|
||||
pub game_state_json: String,
|
||||
pub current_story_json: Option<String>,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = runtime_setting)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeSetting {
|
||||
#[primary_key]
|
||||
pub user_id: String,
|
||||
pub music_volume: f32,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = custom_world_profile)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CustomWorldProfile {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub profile_id: String,
|
||||
#[index(btree)]
|
||||
pub visibility: CustomWorldPublicationStatus,
|
||||
pub payload_json: String,
|
||||
pub published_at_ms: Option<u64>,
|
||||
pub updated_at_ms: u64,
|
||||
pub author_display_name: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub playable_npc_count: u32,
|
||||
pub landmark_count: u32,
|
||||
pub deleted_at_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[table(accessor = custom_world_session)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CustomWorldSession {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub session_id: String,
|
||||
pub payload_json: String,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = profile_dashboard_state)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProfileDashboardState {
|
||||
#[primary_key]
|
||||
pub user_id: String,
|
||||
pub wallet_balance: i64,
|
||||
pub total_play_time_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = profile_wallet_ledger)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProfileWalletLedger {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub amount_delta: i64,
|
||||
pub balance_after: i64,
|
||||
pub source_type: String,
|
||||
pub source_key: String,
|
||||
pub created_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = profile_played_world)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProfilePlayedWorld {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub world_key: String,
|
||||
pub owner_user_id: Option<String>,
|
||||
pub profile_id: Option<String>,
|
||||
pub world_type: Option<String>,
|
||||
pub world_title: String,
|
||||
pub world_subtitle: String,
|
||||
pub first_played_at_ms: u64,
|
||||
pub last_played_at_ms: u64,
|
||||
pub last_observed_play_time_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = user_browse_history)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UserBrowseHistory {
|
||||
#[primary_key]
|
||||
pub id: String,
|
||||
#[index(btree)]
|
||||
pub user_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub profile_id: String,
|
||||
pub world_name: String,
|
||||
pub subtitle: String,
|
||||
pub summary_text: String,
|
||||
pub cover_image_src: Option<String>,
|
||||
pub theme_mode: CustomWorldThemeMode,
|
||||
pub author_display_name: String,
|
||||
pub visited_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = kick_event, public, event)]
|
||||
pub struct KickEvent {
|
||||
pub target_identity: Identity,
|
||||
pub reason_code: String,
|
||||
pub message: String,
|
||||
pub issued_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = verification_prompt_event, public, event)]
|
||||
pub struct VerificationPromptEvent {
|
||||
pub target_identity: Identity,
|
||||
pub phone_number_masked: Option<String>,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
pub issued_at_ms: u64,
|
||||
}
|
||||
|
||||
#[table(accessor = session_revocation_event, public, event)]
|
||||
pub struct SessionRevocationEventRow {
|
||||
pub target_session_id: String,
|
||||
pub reason_code: String,
|
||||
pub message: String,
|
||||
pub issued_at_ms: u64,
|
||||
}
|
||||
@@ -42,6 +42,10 @@ type AccountModalProps = {
|
||||
|
||||
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'guest':
|
||||
return '游客登录';
|
||||
case 'jwt':
|
||||
return '令牌登录';
|
||||
case 'wechat':
|
||||
return '微信登录';
|
||||
case 'phone':
|
||||
|
||||
@@ -7,70 +7,70 @@ import type { AuthUser } from '../../services/authService';
|
||||
import { AuthGate } from './AuthGate';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
getAuthAuditLogs: vi.fn(async () => []),
|
||||
getAuthRiskBlocks: vi.fn(async () => []),
|
||||
getAuthSessions: vi.fn(async () => []),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
}));
|
||||
|
||||
vi.mock('../../spacetime/client', () => ({
|
||||
SPACETIME_KICK_EVENT: 'genarrative-spacetime-kick',
|
||||
SPACETIME_SESSION_REVOKED_EVENT: 'genarrative-spacetime-session-revoked',
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT:
|
||||
'genarrative-spacetime-verification-required',
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: authMocks.getCaptchaChallengeFromError,
|
||||
liftAuthRiskBlock: authMocks.liftAuthRiskBlock,
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
revokeAuthSession: authMocks.revokeAuthSession,
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
vi.mock('./PhoneVerificationModal', () => ({
|
||||
PhoneVerificationModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div>完成短信验证</div> : null,
|
||||
}));
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
const activeUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
username: 'guest_1',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'guest',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
username: 'guest_tester',
|
||||
password: 'auto_password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate prefers login screen when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
test('auth gate renders app content after spacetime auth session is ready', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
@@ -80,7 +80,23 @@ test('auth gate prefers login screen when phone login is available', async () =>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('账号登录')).toBeTruthy();
|
||||
expect(screen.getByText('手机号')).toBeTruthy();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate opens phone verification modal for pending sms verification user', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: {
|
||||
...activeUser,
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
},
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('完成短信验证')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
SPACETIME_KICK_EVENT,
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
type KickEventDetail,
|
||||
type SessionRevokedDetail,
|
||||
type VerificationRequiredDetail,
|
||||
} from '../../spacetime/client';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
@@ -27,41 +25,28 @@ import {
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AUTH_STATE_EVENT } from '../../services/apiClient';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { PhoneVerificationModal } from './PhoneVerificationModal';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
|
||||
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [availableLoginMethods, setAvailableLoginMethods] = useState<
|
||||
AuthLoginMethod[]
|
||||
>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||
const [verificationPrompt, setVerificationPrompt] =
|
||||
useState<VerificationRequiredDetail | null>(null);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
@@ -69,9 +54,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
|
||||
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
|
||||
const [verificationCaptchaChallenge, setVerificationCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
@@ -79,97 +62,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const ensureAutoUser = async () => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('recovering');
|
||||
|
||||
try {
|
||||
const { user: nextUser } = await ensureAutoAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
} catch (autoAuthError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
autoAuthError instanceof Error
|
||||
? autoAuthError.message
|
||||
: '自动登录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(options.availableLoginMethods);
|
||||
return options;
|
||||
};
|
||||
|
||||
const resolveGuestFallback = async () => {
|
||||
try {
|
||||
const options = await loadLoginOptions();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
allowDevGuestAutoAuth &&
|
||||
options &&
|
||||
options.availableLoginMethods.length === 0
|
||||
) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: '读取登录方式失败,请稍后再试。',
|
||||
);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
setStatus((current) => (current === 'ready' ? current : 'recovering'));
|
||||
|
||||
try {
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
@@ -178,43 +72,99 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus('unauthenticated');
|
||||
setStatus('error');
|
||||
setError('账号初始化失败,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
if (nextSession.user.bindingStatus === 'pending_bind_phone') {
|
||||
setShowVerificationModal(true);
|
||||
setVerificationPrompt((current) =>
|
||||
current ?? {
|
||||
phoneNumberMasked: nextSession.user.phoneNumberMasked,
|
||||
title: '完成短信验证',
|
||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (hydrateError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await resolveGuestFallback();
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
hydrateError instanceof Error
|
||||
? hydrateError.message
|
||||
: '账号初始化失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
|
||||
const handleAuthStateChange = () => {
|
||||
setStatus('checking');
|
||||
void hydrate();
|
||||
};
|
||||
|
||||
const handleVerificationRequired = (event: Event) => {
|
||||
const detail = (event as CustomEvent<VerificationRequiredDetail>).detail;
|
||||
setVerificationPrompt(
|
||||
detail ?? {
|
||||
phoneNumberMasked: user?.phoneNumberMasked ?? null,
|
||||
title: '完成短信验证',
|
||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
},
|
||||
);
|
||||
setShowVerificationModal(true);
|
||||
};
|
||||
|
||||
const handleKick = (event: Event) => {
|
||||
const detail = (event as CustomEvent<KickEventDetail>).detail;
|
||||
if (detail?.message) {
|
||||
setError(detail.message);
|
||||
}
|
||||
setShowVerificationModal(true);
|
||||
};
|
||||
|
||||
const handleSessionRevoked = (event: Event) => {
|
||||
const detail = (event as CustomEvent<SessionRevokedDetail>).detail;
|
||||
if (detail?.message) {
|
||||
setError(detail.message);
|
||||
} else {
|
||||
setError('当前连接已失效,请重新建立连接。');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.addEventListener(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
handleVerificationRequired,
|
||||
);
|
||||
window.addEventListener(SPACETIME_KICK_EVENT, handleKick);
|
||||
window.addEventListener(
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
handleSessionRevoked,
|
||||
);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
handleVerificationRequired,
|
||||
);
|
||||
window.removeEventListener(SPACETIME_KICK_EVENT, handleKick);
|
||||
window.removeEventListener(
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
handleSessionRevoked,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [user?.phoneNumberMasked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
@@ -225,75 +175,68 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingRiskBlocks(true);
|
||||
setLoadingSessions(true);
|
||||
setLoadingAuditLogs(true);
|
||||
|
||||
void getAuthRiskBlocks()
|
||||
.then((nextBlocks) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setRiskBlocks(nextBlocks);
|
||||
}
|
||||
setRiskBlocks(nextBlocks);
|
||||
})
|
||||
.catch((blockError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
setLoadingRiskBlocks(false);
|
||||
});
|
||||
|
||||
void getAuthSessions()
|
||||
.then((nextSessions) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setSessions(nextSessions);
|
||||
}
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((sessionError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
});
|
||||
|
||||
void getAuthAuditLogs()
|
||||
.then((nextLogs) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setAuditLogs(nextLogs);
|
||||
}
|
||||
setAuditLogs(nextLogs);
|
||||
})
|
||||
.catch((auditError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
setLoadingAuditLogs(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -314,152 +257,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
[user],
|
||||
);
|
||||
|
||||
if (status === 'checking') {
|
||||
if (status === 'checking' || status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在校验登录状态...
|
||||
正在建立账号连接...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在自动创建或恢复账号...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<LoginScreen
|
||||
availableLoginMethods={availableLoginMethods}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
captchaChallenge={bindCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setBindCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setBindingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
? bindError.message
|
||||
: '绑定手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setBindingPhone(false);
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
if (status === 'error' || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">登录状态异常</div>
|
||||
<div className="text-base font-medium text-zinc-50">连接状态异常</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
@@ -502,6 +312,59 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PhoneVerificationModal
|
||||
user={user}
|
||||
isOpen={showVerificationModal}
|
||||
title={verificationPrompt?.title}
|
||||
detail={verificationPrompt?.detail}
|
||||
sendingCode={sendingCode}
|
||||
verifying={verifyingPhone}
|
||||
error={error}
|
||||
captchaChallenge={verificationCaptchaChallenge}
|
||||
onClose={() => setShowVerificationModal(false)}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setVerificationCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setVerificationCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setVerifyingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setVerificationCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setShowVerificationModal(false);
|
||||
} catch (verifyError) {
|
||||
setError(
|
||||
verifyError instanceof Error
|
||||
? verifyError.message
|
||||
: '短信验证失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setVerifyingPhone(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
@@ -520,12 +383,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
@@ -547,12 +404,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
@@ -561,30 +412,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
@@ -609,11 +445,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
|
||||
201
src/components/auth/PhoneVerificationModal.tsx
Normal file
201
src/components/auth/PhoneVerificationModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type PhoneVerificationModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
sendingCode: boolean;
|
||||
verifying: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PhoneVerificationModal({
|
||||
user,
|
||||
isOpen,
|
||||
title = '完成短信验证',
|
||||
detail = '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
sendingCode,
|
||||
verifying,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: PhoneVerificationModalProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setCaptchaAnswer('');
|
||||
setHint('');
|
||||
setCooldownSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds, isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[72] flex items-end justify-center overflow-y-auto bg-black/68 px-4 sm:items-center"
|
||||
style={{
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[28px] border border-emerald-300/18 bg-[linear-gradient(180deg,_rgba(14,19,25,0.98),_rgba(8,11,16,0.99))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-emerald-200/70">
|
||||
验证窗口
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
{detail}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200">
|
||||
当前账号:{user.displayName}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-5 grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifying || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{verifying ? '验证中...' : '完成验证'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,6 +217,10 @@ function formatSnapshotTime(value: string | null | undefined) {
|
||||
|
||||
function describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'guest':
|
||||
return '游客';
|
||||
case 'jwt':
|
||||
return '令牌';
|
||||
case 'phone':
|
||||
return '手机号';
|
||||
case 'wechat':
|
||||
|
||||
66
src/module_bindings/add_reducer.rs
Normal file
66
src/module_bindings/add_reducer.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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 AddArgs {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<AddArgs> for super::Reducer {
|
||||
fn from(args: AddArgs) -> Self {
|
||||
Self::Add { name: args.name }
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AddArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `add`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait add {
|
||||
/// Request that the remote module invoke the reducer `add` 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 [`add:add_then`] to run a callback after the reducer completes.
|
||||
fn add(&self, name: String) -> __sdk::Result<()> {
|
||||
self.add_then(name, |_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `add` 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 add_then(
|
||||
&self,
|
||||
name: String,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl add for super::RemoteReducers {
|
||||
fn add_then(
|
||||
&self,
|
||||
name: String,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AddArgs { name }, callback)
|
||||
}
|
||||
}
|
||||
817
src/module_bindings/mod.rs
Normal file
817
src/module_bindings/mod.rs
Normal file
@@ -0,0 +1,817 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
pub mod add_reducer;
|
||||
pub mod person_table;
|
||||
pub mod person_type;
|
||||
pub mod say_hello_reducer;
|
||||
|
||||
pub use add_reducer::add;
|
||||
pub use person_table::*;
|
||||
pub use person_type::Person;
|
||||
pub use say_hello_reducer::say_hello;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
|
||||
/// One of the reducers defined by this module.
|
||||
///
|
||||
/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events
|
||||
/// to indicate which reducer caused the event.
|
||||
|
||||
pub enum Reducer {
|
||||
Add { name: String },
|
||||
SayHello,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for Reducer {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::Reducer for Reducer {
|
||||
fn reducer_name(&self) -> &'static str {
|
||||
match self {
|
||||
Reducer::Add { .. } => "add",
|
||||
Reducer::SayHello => "say_hello",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
#[allow(clippy::clone_on_copy)]
|
||||
fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
|
||||
match self {
|
||||
Reducer::Add { name } => {
|
||||
__sats::bsatn::to_vec(&add_reducer::AddArgs { name: name.clone() })
|
||||
}
|
||||
Reducer::SayHello => __sats::bsatn::to_vec(&say_hello_reducer::SayHelloArgs {}),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
#[doc(hidden)]
|
||||
pub struct DbUpdate {
|
||||
person: __sdk::TableUpdate<Person>,
|
||||
}
|
||||
|
||||
impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
type Error = __sdk::Error;
|
||||
fn try_from(raw: __ws::v2::TransactionUpdate) -> Result<Self, Self::Error> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_update in __sdk::transaction_update_iter_table_updates(raw) {
|
||||
match &table_update.table_name[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(person_table::parse_table_update(table_update)?),
|
||||
|
||||
unknown => {
|
||||
return Err(__sdk::InternalError::unknown_name(
|
||||
"table",
|
||||
unknown,
|
||||
"DatabaseUpdate",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for DbUpdate {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbUpdate for DbUpdate {
|
||||
fn apply_to_client_cache(
|
||||
&self,
|
||||
cache: &mut __sdk::ClientCache<RemoteModule>,
|
||||
) -> AppliedDiff<'_> {
|
||||
let mut diff = AppliedDiff::default();
|
||||
|
||||
diff.person = cache.apply_diff_to_table::<Person>("person", &self.person);
|
||||
|
||||
diff
|
||||
}
|
||||
fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_rows in raw.tables {
|
||||
match &table_rows.table[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
unknown => {
|
||||
return Err(
|
||||
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_rows in raw.tables {
|
||||
match &table_rows.table[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
unknown => {
|
||||
return Err(
|
||||
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[allow(non_snake_case)]
|
||||
#[doc(hidden)]
|
||||
pub struct AppliedDiff<'r> {
|
||||
person: __sdk::TableAppliedDiff<'r, Person>,
|
||||
__unused: std::marker::PhantomData<&'r ()>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AppliedDiff<'_> {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
fn invoke_row_callbacks(
|
||||
&self,
|
||||
event: &EventContext,
|
||||
callbacks: &mut __sdk::DbCallbacks<RemoteModule>,
|
||||
) {
|
||||
callbacks.invoke_table_row_callbacks::<Person>("person", &self.person, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteModule;
|
||||
|
||||
impl __sdk::InModule for RemoteModule {
|
||||
type Module = Self;
|
||||
}
|
||||
|
||||
/// The `reducers` field of [`EventContext`] and [`DbConnection`],
|
||||
/// with methods provided by extension traits for each reducer defined by the module.
|
||||
pub struct RemoteReducers {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteReducers {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types,
|
||||
/// with methods provided by extension traits for each procedure defined by the module.
|
||||
pub struct RemoteProcedures {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteProcedures {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// The `db` field of [`EventContext`] and [`DbConnection`],
|
||||
/// with methods provided by extension traits for each table defined by the module.
|
||||
pub struct RemoteTables {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteTables {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// A connection to a remote module, including a materialized view of a subset of the database.
|
||||
///
|
||||
/// Connect to a remote module by calling [`DbConnection::builder`]
|
||||
/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor.
|
||||
///
|
||||
/// You must explicitly advance the connection by calling any one of:
|
||||
///
|
||||
/// - [`DbConnection::frame_tick`].
|
||||
#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")]
|
||||
#[cfg_attr(
|
||||
target_arch = "wasm32",
|
||||
doc = "- [`DbConnection::run_background_task`]."
|
||||
)]
|
||||
/// - [`DbConnection::run_async`].
|
||||
/// - [`DbConnection::advance_one_message`].
|
||||
#[cfg_attr(
|
||||
not(target_arch = "wasm32"),
|
||||
doc = "- [`DbConnection::advance_one_message_blocking`]."
|
||||
)]
|
||||
/// - [`DbConnection::advance_one_message_async`].
|
||||
///
|
||||
/// Which of these methods you should call depends on the specific needs of your application,
|
||||
/// but you must call one of them, or else the connection will never progress.
|
||||
pub struct DbConnection {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
#[doc(hidden)]
|
||||
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for DbConnection {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for DbConnection {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl DbConnection {
|
||||
/// Builder-pattern constructor for a connection to a remote module.
|
||||
///
|
||||
/// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection.
|
||||
pub fn builder() -> __sdk::DbConnectionBuilder<RemoteModule> {
|
||||
__sdk::DbConnectionBuilder::new()
|
||||
}
|
||||
|
||||
/// If any WebSocket messages are waiting, process one of them.
|
||||
///
|
||||
/// Returns `true` if a message was processed, or `false` if the queue is empty.
|
||||
/// Callers should invoke this message in a loop until it returns `false`
|
||||
/// or for as much time is available to process messages.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::frame_tick`] each frame
|
||||
/// to fully exhaust the queue whenever time is available.
|
||||
pub fn advance_one_message(&self) -> __sdk::Result<bool> {
|
||||
self.imp.advance_one_message()
|
||||
}
|
||||
|
||||
/// Process one WebSocket message, potentially blocking the current thread until one is received.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::run_threaded`] to spawn a thread
|
||||
/// which advances the connection automatically.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> {
|
||||
self.imp.advance_one_message_blocking()
|
||||
}
|
||||
|
||||
/// Process one WebSocket message, `await`ing until one is received.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::run_async`] to run an `async` loop
|
||||
/// which advances the connection when polled.
|
||||
pub async fn advance_one_message_async(&self) -> __sdk::Result<()> {
|
||||
self.imp.advance_one_message_async().await
|
||||
}
|
||||
|
||||
/// Process all WebSocket messages waiting in the queue,
|
||||
/// then return without `await`ing or blocking the current thread.
|
||||
pub fn frame_tick(&self) -> __sdk::Result<()> {
|
||||
self.imp.frame_tick()
|
||||
}
|
||||
|
||||
/// Spawn a thread which processes WebSocket messages as they are received.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {
|
||||
self.imp.run_threaded()
|
||||
}
|
||||
|
||||
/// Spawn a background task which processes WebSocket messages as they are received.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn run_background_task(&self) {
|
||||
self.imp.run_background_task()
|
||||
}
|
||||
|
||||
/// Run an `async` loop which processes WebSocket messages when polled.
|
||||
pub async fn run_async(&self) -> __sdk::Result<()> {
|
||||
self.imp.run_async().await
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::DbConnection for DbConnection {
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle on a subscribed query.
|
||||
// TODO: Document this better after implementing the new subscription API.
|
||||
#[derive(Clone)]
|
||||
pub struct SubscriptionHandle {
|
||||
imp: __sdk::SubscriptionHandleImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SubscriptionHandle {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::SubscriptionHandle for SubscriptionHandle {
|
||||
fn new(imp: __sdk::SubscriptionHandleImpl<RemoteModule>) -> Self {
|
||||
Self { imp }
|
||||
}
|
||||
|
||||
/// Returns true if this subscription has been terminated due to an unsubscribe call or an error.
|
||||
fn is_ended(&self) -> bool {
|
||||
self.imp.is_ended()
|
||||
}
|
||||
|
||||
/// Returns true if this subscription has been applied and has not yet been unsubscribed.
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
/// Unsubscribe from the query controlled by this `SubscriptionHandle`,
|
||||
/// then run `on_end` when its rows are removed from the client cache.
|
||||
fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback<RemoteModule>) -> __sdk::Result<()> {
|
||||
self.imp.unsubscribe_then(Some(on_end))
|
||||
}
|
||||
|
||||
fn unsubscribe(self) -> __sdk::Result<()> {
|
||||
self.imp.unsubscribe_then(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias trait for a [`__sdk::DbContext`] connected to this module,
|
||||
/// with that trait's associated types bounded to this module's concrete types.
|
||||
///
|
||||
/// Users can use this trait as a boundary on definitions which should accept
|
||||
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
|
||||
pub trait RemoteDbContext:
|
||||
__sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
{
|
||||
}
|
||||
impl<
|
||||
Ctx: __sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>,
|
||||
> RemoteDbContext for Ctx
|
||||
{
|
||||
}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`],
|
||||
/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks.
|
||||
pub struct EventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: __sdk::Event<Reducer>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for EventContext {
|
||||
type Event = __sdk::Event<Reducer>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for EventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::EventContext for EventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`],
|
||||
/// passed to on-reducer callbacks.
|
||||
pub struct ReducerEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: __sdk::ReducerEvent<Reducer>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ReducerEventContext {
|
||||
type Event = __sdk::ReducerEvent<Reducer>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ReducerEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ReducerEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ReducerEventContext for ReducerEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] passed to procedure callbacks.
|
||||
pub struct ProcedureEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ProcedureEventContext {
|
||||
type Event = ();
|
||||
fn event(&self) -> &Self::Event {
|
||||
&()
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProcedureEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ProcedureEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ProcedureEventContext for ProcedureEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks.
|
||||
pub struct SubscriptionEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for SubscriptionEventContext {
|
||||
type Event = ();
|
||||
fn event(&self) -> &Self::Event {
|
||||
&()
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SubscriptionEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for SubscriptionEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::SubscriptionEventContext for SubscriptionEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`],
|
||||
/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks.
|
||||
pub struct ErrorContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: Option<__sdk::Error>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ErrorContext {
|
||||
type Event = Option<__sdk::Error>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ErrorContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ErrorContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ErrorContext for ErrorContext {}
|
||||
|
||||
impl __sdk::SpacetimeModule for RemoteModule {
|
||||
type DbConnection = DbConnection;
|
||||
type EventContext = EventContext;
|
||||
type ReducerEventContext = ReducerEventContext;
|
||||
type ProcedureEventContext = ProcedureEventContext;
|
||||
type SubscriptionEventContext = SubscriptionEventContext;
|
||||
type ErrorContext = ErrorContext;
|
||||
type Reducer = Reducer;
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
type DbUpdate = DbUpdate;
|
||||
type AppliedDiff<'r> = AppliedDiff<'r>;
|
||||
type SubscriptionHandle = SubscriptionHandle;
|
||||
type QueryBuilder = __sdk::QueryBuilder;
|
||||
|
||||
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {
|
||||
person_table::register_table(client_cache);
|
||||
}
|
||||
const ALL_TABLE_NAMES: &'static [&'static str] = &["person"];
|
||||
}
|
||||
111
src/module_bindings/person_table.rs
Normal file
111
src/module_bindings/person_table.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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::person_type::Person;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `person`.
|
||||
///
|
||||
/// Obtain a handle from the [`PersonTableAccess::person`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.person()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.person().on_insert(...)`.
|
||||
pub struct PersonTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<Person>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `person`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait PersonTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`PersonTableHandle`], which mediates access to the table `person`.
|
||||
fn person(&self) -> PersonTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl PersonTableAccess for super::RemoteTables {
|
||||
fn person(&self) -> PersonTableHandle<'_> {
|
||||
PersonTableHandle {
|
||||
imp: self.imp.get_table::<Person>("person"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PersonInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct PersonDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for PersonTableHandle<'ctx> {
|
||||
type Row = Person;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = Person> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = PersonInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PersonInsertCallbackId {
|
||||
PersonInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: PersonInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = PersonDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PersonDeleteCallbackId {
|
||||
PersonDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: PersonDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table = client_cache.get_or_make_table::<Person>("person");
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<Person>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<Person>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `Person`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait personQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `Person`.
|
||||
fn person(&self) -> __sdk::__query_builder::Table<Person>;
|
||||
}
|
||||
|
||||
impl personQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn person(&self) -> __sdk::__query_builder::Table<Person> {
|
||||
__sdk::__query_builder::Table::new("person")
|
||||
}
|
||||
}
|
||||
45
src/module_bindings/person_type.rs
Normal file
45
src/module_bindings/person_type.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 Person {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for Person {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `Person`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct PersonCols {
|
||||
pub name: __sdk::__query_builder::Col<Person, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for Person {
|
||||
type Cols = PersonCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
PersonCols {
|
||||
name: __sdk::__query_builder::Col::new(table_name, "name"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `Person`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct PersonIxCols {}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for Person {
|
||||
type IxCols = PersonIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
PersonIxCols {}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for Person {}
|
||||
62
src/module_bindings/say_hello_reducer.rs
Normal file
62
src/module_bindings/say_hello_reducer.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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 SayHelloArgs {}
|
||||
|
||||
impl From<SayHelloArgs> for super::Reducer {
|
||||
fn from(args: SayHelloArgs) -> Self {
|
||||
Self::SayHello
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SayHelloArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `say_hello`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait say_hello {
|
||||
/// Request that the remote module invoke the reducer `say_hello` 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 [`say_hello:say_hello_then`] to run a callback after the reducer completes.
|
||||
fn say_hello(&self) -> __sdk::Result<()> {
|
||||
self.say_hello_then(|_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `say_hello` 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 say_hello_then(
|
||||
&self,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl say_hello for super::RemoteReducers {
|
||||
fn say_hello_then(
|
||||
&self,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(SayHelloArgs {}, callback)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getCaptchaChallengeFromError,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
const spacetimeMocks = vi.hoisted(() => ({
|
||||
ensureSpacetimeConnection: vi.fn(),
|
||||
disconnectSpacetimeConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../spacetime/client', () => ({
|
||||
ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection,
|
||||
disconnectSpacetimeConnection: spacetimeMocks.disconnectSpacetimeConnection,
|
||||
getCurrentSpacetimeSessionId: vi.fn(() => 'usess_conn0001'),
|
||||
}));
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
@@ -51,169 +45,149 @@ function createMemoryStorage() {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
function createAuthStateRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
userId: 'user_1',
|
||||
identity: {
|
||||
isEqual: vi.fn(() => true),
|
||||
toHexString: vi.fn(() => 'abc'),
|
||||
},
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginProvider: { tag: 'Guest' },
|
||||
accountStatus: { tag: 'Active' },
|
||||
smsVerificationRequired: false,
|
||||
smsVerified: false,
|
||||
jwtPresent: false,
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe('authService auto auth', () => {
|
||||
function createConnection(options: {
|
||||
authRows?: unknown[];
|
||||
configRows?: unknown[];
|
||||
sessionRows?: unknown[];
|
||||
riskBlockRows?: unknown[];
|
||||
sendCodeResult?: Record<string, unknown>;
|
||||
verifyCodeResult?: Record<string, unknown>;
|
||||
liftRiskResult?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
return {
|
||||
connectionId: {
|
||||
toHexString: vi.fn(() => 'conn0001'),
|
||||
},
|
||||
identity: {
|
||||
isEqual: vi.fn(() => true),
|
||||
toHexString: vi.fn(() => 'identity0001'),
|
||||
},
|
||||
procedures: {
|
||||
sendSmsVerificationCode: vi.fn(async () => ({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: null,
|
||||
...options.sendCodeResult,
|
||||
})),
|
||||
verifySmsCode: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '短信验证通过',
|
||||
verified: true,
|
||||
...options.verifyCodeResult,
|
||||
})),
|
||||
liftMyRiskBlock: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '风险保护已解除',
|
||||
...options.liftRiskResult,
|
||||
})),
|
||||
revokeUserSession: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '会话已移除',
|
||||
})),
|
||||
logoutAllUserSessions: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '全部会话已注销',
|
||||
})),
|
||||
},
|
||||
db: {
|
||||
my_auth_state: {
|
||||
iter: vi.fn(() => options.authRows ?? [createAuthStateRow()]),
|
||||
},
|
||||
client_app_config: {
|
||||
iter: vi.fn(() =>
|
||||
options.configRows ?? [
|
||||
{
|
||||
smsAuthEnabled: true,
|
||||
wechatEnabled: false,
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
my_auth_audit_logs: {
|
||||
iter: vi.fn(() => []),
|
||||
},
|
||||
my_user_sessions: {
|
||||
iter: vi.fn(() =>
|
||||
options.sessionRows ?? [
|
||||
{
|
||||
sessionId: 'sess_1',
|
||||
clientType: 'web',
|
||||
userAgent: 'vitest-browser',
|
||||
ip: '127.0.0.1',
|
||||
isCurrent: true,
|
||||
createdAtMs: BigInt(Date.now() - 1_000),
|
||||
lastSeenAtMs: BigInt(Date.now()),
|
||||
expiresAtMs: BigInt(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
my_auth_risk_blocks: {
|
||||
iter: vi.fn(() =>
|
||||
options.riskBlockRows ?? [
|
||||
{
|
||||
scopeType: { tag: 'Phone' },
|
||||
scopeKey: '+8613800138000',
|
||||
reason: 'sms_verify_failed_too_many_times',
|
||||
expiresAtMs: BigInt(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('authService with SpacetimeDB', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
location: {
|
||||
hash: '',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'vitest-browser',
|
||||
});
|
||||
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
||||
spacetimeMocks.disconnectSpacetimeConnection.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
it('creates credentials that match current guest username/password constraints', () => {
|
||||
const credentials = createAutoAuthCredentials();
|
||||
|
||||
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
displayName: 'guest_abc123abc123',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: ' guest_abc123abc123 ',
|
||||
password: ' auto_secret_password ',
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
displayName: 'guest_saveduser01',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-auto',
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
username: 'guest_auto',
|
||||
displayName: 'guest_auto',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
ensureAutoAuthUser(),
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
@@ -241,300 +215,136 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
it('reads current auth session from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(
|
||||
createConnection({
|
||||
authRows: [
|
||||
createAuthStateRow({
|
||||
displayName: '游客阿青',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginProvider: { tag: 'Phone' },
|
||||
smsVerified: true,
|
||||
}),
|
||||
],
|
||||
configRows: [
|
||||
{
|
||||
smsAuthEnabled: true,
|
||||
wechatEnabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const session = await getCurrentAuthUser();
|
||||
|
||||
expect(session.user?.displayName).toBe('游客阿青');
|
||||
expect(session.user?.loginMethod).toBe('phone');
|
||||
expect(session.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
});
|
||||
|
||||
it('sends phone login code through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(connection.procedures.sendSmsVerificationCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800138000',
|
||||
scene: { tag: 'Login' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies phone code and resolves the updated auth user from spacetime', async () => {
|
||||
const connection = createConnection({
|
||||
authRows: [
|
||||
createAuthStateRow({
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginProvider: { tag: 'Phone' },
|
||||
smsVerified: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect(connection.procedures.verifySmsCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
phoneNumber: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(user.loginMethod).toBe('phone');
|
||||
expect(user.bindingStatus).toBe('active');
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
},
|
||||
it('disconnects local spacetime auth session on logout', async () => {
|
||||
await logoutAuthUser();
|
||||
|
||||
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({
|
||||
clearToken: true,
|
||||
});
|
||||
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录方式失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
clientType: 'browser',
|
||||
clientLabel: '网页端浏览器',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-09T10:00:00.000Z',
|
||||
lastSeenAt: '2026-04-09T10:30:00.000Z',
|
||||
expiresAt: '2026-05-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
it('reads auth sessions from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection());
|
||||
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
expect(sessions[0]?.clientType).toBe('web');
|
||||
expect(sessions[0]?.isCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
eventType: 'phone_login',
|
||||
title: '手机号登录',
|
||||
detail: '使用手机号 138****8000 完成登录',
|
||||
ipMasked: '127.0.*.*',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
createdAt: '2026-04-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护中',
|
||||
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
|
||||
expiresAt: '2026-04-09T11:00:00.000Z',
|
||||
remainingSeconds: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
it('reads risk blocks from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection());
|
||||
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
expect(blocks[0]?.scopeType).toBe('phone');
|
||||
expect(blocks[0]?.remainingSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('lifts a risk block through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect(connection.procedures.liftMyRiskBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
scopeType: { tag: 'Phone' },
|
||||
}),
|
||||
'解除保护失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('revokes a user session through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
await revokeAuthSession('sess_1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect(connection.procedures.revokeUserSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
sessionId: 'sess_1',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('logs out all user sessions through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(connection.procedures.logoutAllUserSessions).toHaveBeenCalled();
|
||||
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({
|
||||
clearToken: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
disconnectSpacetimeConnection,
|
||||
ensureSpacetimeConnection,
|
||||
getCurrentSpacetimeSessionId,
|
||||
} from '../spacetime/client';
|
||||
import {
|
||||
mapAuditLogEntry,
|
||||
mapAuthRiskBlock,
|
||||
mapAuthSession,
|
||||
mapAuthUser,
|
||||
mapAvailableLoginMethods,
|
||||
} from '../spacetime/mappers';
|
||||
import type {
|
||||
ClientAppConfigView,
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
} from '../spacetime/generated/types';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
@@ -59,6 +66,106 @@ let pendingAutoAuthUser: Promise<{
|
||||
credentials: AutoAuthCredentials;
|
||||
}> | null = null;
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function getSingleRow<Row>(rows: Iterable<Row>) {
|
||||
for (const row of rows) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRequestMeta(): RequestMeta {
|
||||
return {
|
||||
clientType: 'web',
|
||||
userAgent:
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
||||
ip: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSmsScene(
|
||||
scene: 'login' | 'bind_phone' | 'change_phone',
|
||||
): SmsAuthScene {
|
||||
switch (scene) {
|
||||
case 'bind_phone':
|
||||
return { tag: 'BindPhone' };
|
||||
case 'change_phone':
|
||||
return { tag: 'ChangePhone' };
|
||||
default:
|
||||
return { tag: 'Login' };
|
||||
}
|
||||
}
|
||||
|
||||
async function readCurrentSessionFromConnection() {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const authRow = getSingleRow(connection.db.my_auth_state.iter());
|
||||
const configRow = getSingleRow(
|
||||
connection.db.client_app_config.iter(),
|
||||
) as ClientAppConfigView | null;
|
||||
|
||||
return {
|
||||
user: authRow ? mapAuthUser(authRow) : null,
|
||||
availableLoginMethods: mapAvailableLoginMethods(configRow),
|
||||
} satisfies AuthSessionSnapshot;
|
||||
}
|
||||
|
||||
async function readCurrentSessionWithRetry() {
|
||||
try {
|
||||
return await readCurrentSessionFromConnection();
|
||||
} catch (error) {
|
||||
if (!getStoredAccessToken()) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
disconnectSpacetimeConnection();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
return readCurrentSessionFromConnection();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuthUser(
|
||||
predicate: (user: AuthUser | null) => boolean,
|
||||
timeoutMs = 1200,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const session = await readCurrentSessionFromConnection();
|
||||
if (predicate(session.user)) {
|
||||
return session.user;
|
||||
}
|
||||
await sleep(40);
|
||||
}
|
||||
|
||||
throw new Error('账号状态同步超时,请稍后重试');
|
||||
}
|
||||
|
||||
function toProcedureError(message: string) {
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
@@ -73,7 +180,8 @@ export function getCaptchaChallengeFromError(
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
|
||||
const challenge = (error.details as { captchaChallenge?: unknown })
|
||||
.captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
@@ -89,29 +197,6 @@ export function getCaptchaChallengeFromError(
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
|
||||
return {
|
||||
username: credentials.username.trim(),
|
||||
password: credentials.password.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{length},
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
return {
|
||||
username: `guest_${buildRandomSegment(12)}`,
|
||||
@@ -120,6 +205,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
disconnectSpacetimeConnection();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
@@ -127,141 +213,94 @@ export function clearAuthSession() {
|
||||
export async function sendPhoneLoginCode(
|
||||
phone: string,
|
||||
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
|
||||
captcha?: {
|
||||
_captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<AuthPhoneSendCodeResponse>(
|
||||
'/api/auth/phone/send-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'发送验证码失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.sendSmsVerificationCode({
|
||||
meta: buildRequestMeta(),
|
||||
phoneNumber: normalizePhoneInput(phone),
|
||||
scene: mapSmsScene(scene),
|
||||
});
|
||||
|
||||
return response;
|
||||
if (!result.ok) {
|
||||
throw toProcedureError(result.message || '发送验证码失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cooldownSeconds: Number(result.cooldownSeconds),
|
||||
expiresInSeconds: Number(result.expiresInSeconds),
|
||||
providerRequestId: result.providerRequestId ?? null,
|
||||
} satisfies AuthPhoneSendCodeResponse;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.verifySmsCode({
|
||||
meta: buildRequestMeta(),
|
||||
phoneNumber: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
});
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
if (!result.ok) {
|
||||
throw toProcedureError(result.message || '登录失败');
|
||||
}
|
||||
|
||||
const nextUser = await waitForAuthUser(
|
||||
(user) => user?.bindingStatus === 'active',
|
||||
);
|
||||
if (!nextUser) {
|
||||
throw new Error('登录状态同步失败');
|
||||
}
|
||||
|
||||
return nextUser satisfies AuthPhoneLoginResponse['user'];
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
const user = await loginWithPhoneCode(phone, code);
|
||||
return user satisfies AuthWechatBindPhoneResponse['user'];
|
||||
}
|
||||
|
||||
export async function changePhoneNumber(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneChangeResponse>(
|
||||
'/api/auth/phone/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'更换手机号失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
const user = await loginWithPhoneCode(phone, code);
|
||||
return user satisfies AuthPhoneChangeResponse['user'];
|
||||
}
|
||||
|
||||
export async function startWechatLogin() {
|
||||
const response = await requestJson<AuthWechatStartResponse>(
|
||||
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
throw new Error('当前版本暂未接入微信登录流程');
|
||||
}
|
||||
|
||||
export async function getAuthLoginOptions() {
|
||||
return requestJson<AuthLoginOptionsResponse>(
|
||||
'/api/auth/login-options',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录方式失败',
|
||||
);
|
||||
const session = await readCurrentSessionWithRetry();
|
||||
return {
|
||||
availableLoginMethods: session.availableLoginMethods,
|
||||
} satisfies AuthLoginOptionsResponse;
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
export async function authEntry(_username: string, _password: string) {
|
||||
const session = await readCurrentSessionWithRetry();
|
||||
if (!session.user) {
|
||||
throw new Error('创建游客账号失败');
|
||||
}
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function authEntryWithStoredCredentials(
|
||||
credentials: AutoAuthCredentials,
|
||||
) {
|
||||
const normalizedCredentials = normalizeCredentials(credentials);
|
||||
const user = await authEntry(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
const user = await authEntry(credentials.username, credentials.password);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
const user = await authEntry('guest', 'guest');
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
credentials: createAutoAuthCredentials(),
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -285,19 +324,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const authToken = params.get('auth_token');
|
||||
const authError = params.get('auth_error');
|
||||
const providerValue = params.get('auth_provider');
|
||||
const bindingStatus = params.get('auth_binding_status');
|
||||
|
||||
if (!authToken && !authError) {
|
||||
if (!authError && !providerValue && !bindingStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken);
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
@@ -314,100 +348,77 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
const response = await requestJson<AuthMeResponse>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取当前用户失败',
|
||||
);
|
||||
|
||||
return {
|
||||
user: response.user,
|
||||
availableLoginMethods: response.availableLoginMethods,
|
||||
};
|
||||
return readCurrentSessionWithRetry();
|
||||
}
|
||||
|
||||
export async function getAuthSessions() {
|
||||
const response = await requestJson<AuthSessionsResponse>(
|
||||
'/api/auth/sessions',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录设备失败',
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const currentSessionId = getCurrentSpacetimeSessionId(connection);
|
||||
return Array.from(connection.db.my_user_sessions.iter()).map((row) =>
|
||||
mapAuthSession(row, { currentSessionId }),
|
||||
);
|
||||
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string) {
|
||||
await requestJson<AuthRevokeSessionResponse>(
|
||||
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'移除登录设备失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.revokeUserSession({
|
||||
meta: buildRequestMeta(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '移除登录设备失败');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
|
||||
return response.logs;
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_auth_audit_logs.iter()).map(mapAuditLogEntry);
|
||||
}
|
||||
|
||||
export async function getAuthRiskBlocks() {
|
||||
const response = await requestJson<AuthRiskBlocksResponse>(
|
||||
'/api/auth/risk-blocks',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取安全状态失败',
|
||||
);
|
||||
|
||||
return response.blocks;
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_auth_risk_blocks.iter())
|
||||
.map(mapAuthRiskBlock)
|
||||
.filter((block) => block.remainingSeconds > 0);
|
||||
}
|
||||
|
||||
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
|
||||
await requestJson<AuthLiftRiskBlockResponse>(
|
||||
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'解除保护失败',
|
||||
);
|
||||
export async function liftAuthRiskBlock(_scopeType: 'phone' | 'ip') {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.liftMyRiskBlock({
|
||||
meta: buildRequestMeta(),
|
||||
scopeType:
|
||||
_scopeType === 'phone' ? { tag: 'Phone' } : { tag: 'Ip' },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '解除保护失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies AuthLiftRiskBlockResponse;
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<LogoutResponse>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
disconnectSpacetimeConnection({ clearToken: true });
|
||||
clearStoredAutoAuthCredentials();
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies LogoutResponse;
|
||||
}
|
||||
|
||||
export async function logoutAllAuthSessions() {
|
||||
try {
|
||||
await requestJson<AuthLogoutAllResponse>(
|
||||
'/api/auth/logout-all',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出全部设备失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.logoutAllUserSessions({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '退出全部设备失败');
|
||||
}
|
||||
disconnectSpacetimeConnection({ clearToken: true });
|
||||
clearStoredAutoAuthCredentials();
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies AuthLogoutAllResponse;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
listProfileBrowseHistory,
|
||||
putSettings,
|
||||
syncProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
} from './storageService';
|
||||
|
||||
vi.mock('./apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
const spacetimeMocks = vi.hoisted(() => ({
|
||||
ensureSpacetimeConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storageService browse history routes', () => {
|
||||
vi.mock('../spacetime/client', () => ({
|
||||
ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection,
|
||||
}));
|
||||
|
||||
function createConnection() {
|
||||
return {
|
||||
procedures: {
|
||||
upsertPlatformBrowseHistory: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
clearPlatformBrowseHistory: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
putRuntimeSettings: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
},
|
||||
db: {
|
||||
my_browse_history: {
|
||||
iter: vi.fn(() => [
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: { tag: 'Mythic' },
|
||||
authorDisplayName: '测试作者',
|
||||
visitedAtMs: BigInt(Date.now()),
|
||||
},
|
||||
]),
|
||||
},
|
||||
my_runtime_settings: {
|
||||
iter: vi.fn(() => [
|
||||
{
|
||||
musicVolume: 0.66,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storageService with SpacetimeDB', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'vitest-browser',
|
||||
});
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listProfileBrowseHistory();
|
||||
it('reads browse history from spacetime views', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
const entries = await listProfileBrowseHistory();
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.worldName).toBe('测试世界');
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
it('writes single browse history entry through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await upsertProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -46,23 +92,23 @@ describe('storageService browse history routes', () => {
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
it('syncs browse history batch through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await syncProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
@@ -76,30 +122,34 @@ describe('storageService browse history routes', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
entries: [expect.any(Object)],
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearProfileBrowseHistory();
|
||||
it('clears browse history through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
const entries = await clearProfileBrowseHistory();
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
expect(connection.procedures.clearPlatformBrowseHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes runtime settings through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const settings = await putSettings({ musicVolume: 0.5 });
|
||||
|
||||
expect(connection.procedures.putRuntimeSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
musicVolume: 0.5,
|
||||
}),
|
||||
);
|
||||
expect(settings.musicVolume).toBe(0.66);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,7 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
@@ -19,339 +17,436 @@ import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
import { ensureSpacetimeConnection } from '../spacetime/client';
|
||||
import {
|
||||
mapBrowseHistoryEntry,
|
||||
mapCustomWorldLibraryEntry,
|
||||
mapCustomWorldSession,
|
||||
mapGalleryCard,
|
||||
mapPlayedWorldEntry,
|
||||
mapProfileDashboard,
|
||||
mapPublishedProfile,
|
||||
mapRuntimeSettings,
|
||||
mapSnapshotRow,
|
||||
mapWalletLedgerEntry,
|
||||
} from '../spacetime/mappers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
function toBigIntMs(isoValue?: string) {
|
||||
if (!isoValue) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
const ms = Date.parse(isoValue);
|
||||
return Number.isFinite(ms) ? BigInt(ms) : 0n;
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
function buildRequestMeta() {
|
||||
return {
|
||||
clientType: 'web',
|
||||
userAgent:
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
||||
ip: null,
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
function mapThemeModeInput(
|
||||
themeMode: PlatformBrowseHistoryWriteEntry['themeMode'],
|
||||
) {
|
||||
switch (themeMode) {
|
||||
case 'martial':
|
||||
return { tag: 'Martial' } as const;
|
||||
case 'arcane':
|
||||
return { tag: 'Arcane' } as const;
|
||||
case 'machina':
|
||||
return { tag: 'Machina' } as const;
|
||||
case 'tide':
|
||||
return { tag: 'Tide' } as const;
|
||||
case 'rift':
|
||||
return { tag: 'Rift' } as const;
|
||||
default:
|
||||
return { tag: 'Mythic' } as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSnapshot(timeoutMs = 1200) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const snapshot = Array.from(connection.db.my_snapshot.iter())[0];
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
throw new Error('远端存档同步超时');
|
||||
}
|
||||
|
||||
async function waitForRuntimeSettings(timeoutMs = 1200) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_runtime_settings.iter())[0];
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_snapshot.iter())[0];
|
||||
return row ? rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot) : null;
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.saveSnapshot({
|
||||
meta: buildRequestMeta(),
|
||||
savedAtMs: toBigIntMs(snapshot.savedAt),
|
||||
gameStateJson: JSON.stringify(snapshot.gameState),
|
||||
bottomTab: snapshot.bottomTab,
|
||||
currentStoryJson:
|
||||
snapshot.currentStory === null || snapshot.currentStory === undefined
|
||||
? null
|
||||
: JSON.stringify(snapshot.currentStory),
|
||||
});
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存存档失败');
|
||||
}
|
||||
|
||||
const row = await waitForSnapshot();
|
||||
return rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
export async function deleteSaveSnapshot(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.deleteSnapshot({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '删除存档失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies BasicOkResult;
|
||||
}
|
||||
|
||||
export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
export async function getSettings(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_runtime_settings.iter())[0] ?? null;
|
||||
return mapRuntimeSettings(row);
|
||||
}
|
||||
|
||||
export async function getProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
export async function getProfileDashboard(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null;
|
||||
return mapProfileDashboard(row);
|
||||
}
|
||||
|
||||
export async function getProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const entries = Array.from(connection.db.my_profile_wallet_ledger.iter()).map(
|
||||
mapWalletLedgerEntry,
|
||||
);
|
||||
return {
|
||||
entries,
|
||||
} satisfies ProfileWalletLedgerResponse;
|
||||
}
|
||||
|
||||
export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
export async function getProfilePlayStats(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const dashboard = mapProfileDashboard(
|
||||
Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null,
|
||||
);
|
||||
return {
|
||||
totalPlayTimeMs: dashboard.totalPlayTimeMs,
|
||||
playedWorks: Array.from(connection.db.my_profile_played_worlds.iter()).map(
|
||||
mapPlayedWorldEntry,
|
||||
),
|
||||
updatedAt: dashboard.updatedAt,
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.putRuntimeSettings({
|
||||
meta: buildRequestMeta(),
|
||||
musicVolume: settings.musicVolume,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存设置失败');
|
||||
}
|
||||
|
||||
const row = await waitForRuntimeSettings();
|
||||
return row ? mapRuntimeSettings(row) : { musicVolume: DEFAULT_MUSIC_VOLUME };
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_custom_world_profiles.iter()).map(
|
||||
mapCustomWorldLibraryEntry,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
return {
|
||||
items: [],
|
||||
} satisfies ListCustomWorldWorksResponse;
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId: profile.id,
|
||||
payloadJson: JSON.stringify(profile),
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry =
|
||||
entries.find((item) => item.profileId === profile.id) ??
|
||||
mapCustomWorldLibraryEntry({
|
||||
ownerUserId: '',
|
||||
profileId: profile.id,
|
||||
payloadJson: JSON.stringify(profile),
|
||||
visibility: { tag: 'Draft' },
|
||||
publishedAtMs: null,
|
||||
updatedAtMs: BigInt(Date.now()),
|
||||
authorDisplayName: '玩家',
|
||||
worldName: profile.name,
|
||||
subtitle: profile.subtitle,
|
||||
summaryText: profile.summary,
|
||||
coverImageSrc: null,
|
||||
themeMode: { tag: 'Mythic' },
|
||||
playableNpcCount: profile.playableNpcs.length,
|
||||
landmarkCount: profile.landmarks.length,
|
||||
});
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.deleteCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '删除自定义世界失败');
|
||||
}
|
||||
|
||||
return listCustomWorldLibrary();
|
||||
}
|
||||
|
||||
export async function publishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.publishCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '发布自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry = entries.find((item) => item.profileId === profileId);
|
||||
if (!entry) {
|
||||
throw new Error('发布后未找到自定义世界');
|
||||
}
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function unpublishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.unpublishCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '下架自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry = entries.find((item) => item.profileId === profileId);
|
||||
if (!entry) {
|
||||
throw new Error('下架后未找到自定义世界');
|
||||
}
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.published_custom_world_gallery.iter()).map(
|
||||
mapGalleryCard,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getCustomWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const entry = Array.from(connection.db.published_custom_world_profiles.iter())
|
||||
.map(mapPublishedProfile)
|
||||
.find(
|
||||
(row) =>
|
||||
row.ownerUserId === ownerUserId && row.profileId === profileId,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
if (!entry) {
|
||||
throw new Error('读取作品详情失败');
|
||||
}
|
||||
|
||||
return entry satisfies CustomWorldGalleryDetailResponse<CustomWorldProfile>['entry'];
|
||||
}
|
||||
|
||||
export async function listProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_browse_history.iter()).map(
|
||||
mapBrowseHistoryEntry,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
entries: [
|
||||
{
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: mapThemeModeInput(entry.themeMode),
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '写入浏览历史失败');
|
||||
}
|
||||
|
||||
return listProfileBrowseHistory();
|
||||
}
|
||||
|
||||
export async function syncProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
entries: entries.map((entry) => ({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: mapThemeModeInput(entry.themeMode),
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n,
|
||||
})),
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '同步浏览历史失败');
|
||||
}
|
||||
|
||||
return listProfileBrowseHistory();
|
||||
}
|
||||
|
||||
export async function clearProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.clearPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '清空浏览历史失败');
|
||||
}
|
||||
|
||||
return [] satisfies PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
async function listCustomWorldSessions() {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_custom_world_sessions.iter()).map(
|
||||
mapCustomWorldSession,
|
||||
);
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
|
||||
242
src/spacetime/client.ts
Normal file
242
src/spacetime/client.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Identity } from 'spacetimedb';
|
||||
|
||||
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||
import { DbConnection } from './generated';
|
||||
|
||||
export const SPACETIME_VERIFICATION_REQUIRED_EVENT =
|
||||
'genarrative-spacetime-verification-required';
|
||||
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
|
||||
export const SPACETIME_SESSION_REVOKED_EVENT =
|
||||
'genarrative-spacetime-session-revoked';
|
||||
|
||||
export type VerificationRequiredDetail = {
|
||||
phoneNumberMasked: string | null;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type KickEventDetail = {
|
||||
reasonCode: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SessionRevokedDetail = {
|
||||
targetSessionId: string;
|
||||
reasonCode: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
let activeConnection: DbConnection | null = null;
|
||||
let readyPromise: Promise<DbConnection> | null = null;
|
||||
let resolveReady: ((connection: DbConnection) => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
let hasActiveSubscription = false;
|
||||
|
||||
function emitWindowEvent<T>(eventName: string, detail?: T) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(eventName));
|
||||
}
|
||||
|
||||
function emitAuthStateChange() {
|
||||
emitWindowEvent(AUTH_STATE_EVENT);
|
||||
}
|
||||
|
||||
function normalizeSpacetimeUri(rawValue: string) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return 'ws://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('http://')) {
|
||||
return `ws://${trimmed.slice('http://'.length)}`;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('https://')) {
|
||||
return `wss://${trimmed.slice('https://'.length)}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveSpacetimeUri() {
|
||||
return normalizeSpacetimeUri(
|
||||
import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000',
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDatabaseName() {
|
||||
return (
|
||||
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr'
|
||||
);
|
||||
}
|
||||
|
||||
function isTargetIdentity(connection: DbConnection, identity: Identity) {
|
||||
return Boolean(connection.identity && connection.identity.isEqual(identity));
|
||||
}
|
||||
|
||||
export function getCurrentSpacetimeSessionId(connection: DbConnection | null) {
|
||||
if (!connection) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const connectionIdHex = connection.connectionId?.toHexString()?.trim();
|
||||
if (connectionIdHex) {
|
||||
return `usess_${connectionIdHex}`;
|
||||
}
|
||||
|
||||
const identityHex = connection.identity?.toHexString()?.trim();
|
||||
return identityHex ? `usess_${identityHex}` : '';
|
||||
}
|
||||
|
||||
function installConnectionCallbacks(connection: DbConnection) {
|
||||
connection.db.my_auth_state.onInsert(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.my_auth_state.onUpdate(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.my_auth_state.onDelete(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.verification_prompt_event.onInsert((_ctx, row) => {
|
||||
if (!isTargetIdentity(connection, row.targetIdentity)) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<VerificationRequiredDetail>(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
{
|
||||
phoneNumberMasked: row.phoneNumberMasked ?? null,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
},
|
||||
);
|
||||
});
|
||||
connection.db.kick_event.onInsert((_ctx, row) => {
|
||||
if (!isTargetIdentity(connection, row.targetIdentity)) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<KickEventDetail>(SPACETIME_KICK_EVENT, {
|
||||
reasonCode: row.reasonCode,
|
||||
message: row.message,
|
||||
});
|
||||
});
|
||||
connection.db.session_revocation_event.onInsert((_ctx, row) => {
|
||||
const currentSessionId = getCurrentSpacetimeSessionId(connection);
|
||||
if (!currentSessionId || row.targetSessionId !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<SessionRevokedDetail>(SPACETIME_SESSION_REVOKED_EVENT, {
|
||||
targetSessionId: row.targetSessionId,
|
||||
reasonCode: row.reasonCode,
|
||||
message: row.message,
|
||||
});
|
||||
connection.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function resetReadyState() {
|
||||
readyPromise = null;
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
hasActiveSubscription = false;
|
||||
}
|
||||
|
||||
function rejectConnection(error: Error) {
|
||||
rejectReady?.(error);
|
||||
resetReadyState();
|
||||
}
|
||||
|
||||
export function getSpacetimeConnection() {
|
||||
return activeConnection;
|
||||
}
|
||||
|
||||
export function disconnectSpacetimeConnection(options: { clearToken?: boolean } = {}) {
|
||||
const currentConnection = activeConnection;
|
||||
activeConnection = null;
|
||||
resetReadyState();
|
||||
currentConnection?.disconnect();
|
||||
if (options.clearToken) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpacetimeConnection() {
|
||||
const connection = DbConnection.builder()
|
||||
.withUri(resolveSpacetimeUri())
|
||||
.withDatabaseName(resolveDatabaseName())
|
||||
.withLightMode(true)
|
||||
.withToken(getStoredAccessToken() || undefined)
|
||||
.onConnect((nextConnection, _identity, token) => {
|
||||
activeConnection = nextConnection;
|
||||
setStoredAccessToken(token, { emit: false });
|
||||
installConnectionCallbacks(nextConnection);
|
||||
if (hasActiveSubscription) {
|
||||
resolveReady?.(nextConnection);
|
||||
emitAuthStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
nextConnection
|
||||
.subscriptionBuilder()
|
||||
.onApplied(() => {
|
||||
hasActiveSubscription = true;
|
||||
resolveReady?.(nextConnection);
|
||||
emitAuthStateChange();
|
||||
})
|
||||
.onError((_ctx) => {
|
||||
rejectConnection(new Error('Spacetime 数据订阅失败'));
|
||||
})
|
||||
.subscribeToAllTables();
|
||||
})
|
||||
.onConnectError((_ctx, error) => {
|
||||
activeConnection = null;
|
||||
rejectConnection(error);
|
||||
})
|
||||
.onDisconnect((_ctx, error) => {
|
||||
activeConnection = null;
|
||||
if (!hasActiveSubscription) {
|
||||
rejectConnection(error ?? new Error('Spacetime 连接已断开'));
|
||||
return;
|
||||
}
|
||||
resetReadyState();
|
||||
emitAuthStateChange();
|
||||
})
|
||||
.build();
|
||||
|
||||
activeConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function ensureSpacetimeConnection() {
|
||||
if (activeConnection?.isActive && hasActiveSubscription && readyPromise) {
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
if (activeConnection?.isActive && hasActiveSubscription) {
|
||||
return Promise.resolve(activeConnection);
|
||||
}
|
||||
|
||||
if (readyPromise) {
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
readyPromise = new Promise<DbConnection>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
buildSpacetimeConnection();
|
||||
return readyPromise;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
24
src/spacetime/generated/client_app_config_table.ts
Normal file
24
src/spacetime/generated/client_app_config_table.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
guestLoginEnabled: __t.bool().name("guest_login_enabled"),
|
||||
smsAuthEnabled: __t.bool().name("sms_auth_enabled"),
|
||||
smsVerificationRequired: __t.bool().name("sms_verification_required"),
|
||||
smsProvider: __t.string().name("sms_provider"),
|
||||
smsCodeLength: __t.u16().name("sms_code_length"),
|
||||
smsValidTimeSeconds: __t.u32().name("sms_valid_time_seconds"),
|
||||
smsIntervalSeconds: __t.u32().name("sms_interval_seconds"),
|
||||
defaultMusicVolume: __t.f32().name("default_music_volume"),
|
||||
defaultGuestDisplayNamePrefix: __t.string().name("default_guest_display_name_prefix"),
|
||||
wechatEnabled: __t.bool().name("wechat_enabled"),
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
23
src/spacetime/generated/delete_snapshot_procedure.ts
Normal file
23
src/spacetime/generated/delete_snapshot_procedure.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
282
src/spacetime/generated/index.ts
Normal file
282
src/spacetime/generated/index.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
DbConnectionBuilder as __DbConnectionBuilder,
|
||||
DbConnectionImpl as __DbConnectionImpl,
|
||||
SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
|
||||
TypeBuilder as __TypeBuilder,
|
||||
Uuid as __Uuid,
|
||||
convertToAccessorMap as __convertToAccessorMap,
|
||||
makeQueryBuilder as __makeQueryBuilder,
|
||||
procedureSchema as __procedureSchema,
|
||||
procedures as __procedures,
|
||||
reducerSchema as __reducerSchema,
|
||||
reducers as __reducers,
|
||||
schema as __schema,
|
||||
t as __t,
|
||||
table as __table,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type DbConnectionConfig as __DbConnectionConfig,
|
||||
type ErrorContextInterface as __ErrorContextInterface,
|
||||
type Event as __Event,
|
||||
type EventContextInterface as __EventContextInterface,
|
||||
type Infer as __Infer,
|
||||
type QueryBuilder as __QueryBuilder,
|
||||
type ReducerEventContextInterface as __ReducerEventContextInterface,
|
||||
type RemoteModule as __RemoteModule,
|
||||
type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
|
||||
type SubscriptionHandleImpl as __SubscriptionHandleImpl,
|
||||
} from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
|
||||
// Import all procedure arg schemas
|
||||
import * as ClearPlatformBrowseHistoryProcedure from "./clear_platform_browse_history_procedure";
|
||||
import * as DeleteCustomWorldProfileProcedure from "./delete_custom_world_profile_procedure";
|
||||
import * as DeleteSnapshotProcedure from "./delete_snapshot_procedure";
|
||||
import * as LiftMyRiskBlockProcedure from "./lift_my_risk_block_procedure";
|
||||
import * as LogoutAllUserSessionsProcedure from "./logout_all_user_sessions_procedure";
|
||||
import * as PublishCustomWorldProfileProcedure from "./publish_custom_world_profile_procedure";
|
||||
import * as PutRuntimeSettingsProcedure from "./put_runtime_settings_procedure";
|
||||
import * as RevokeUserSessionProcedure from "./revoke_user_session_procedure";
|
||||
import * as SaveSnapshotProcedure from "./save_snapshot_procedure";
|
||||
import * as SendSmsVerificationCodeProcedure from "./send_sms_verification_code_procedure";
|
||||
import * as UnpublishCustomWorldProfileProcedure from "./unpublish_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldProfileProcedure from "./upsert_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldSessionProcedure from "./upsert_custom_world_session_procedure";
|
||||
import * as UpsertPlatformBrowseHistoryProcedure from "./upsert_platform_browse_history_procedure";
|
||||
import * as VerifySmsCodeProcedure from "./verify_sms_code_procedure";
|
||||
|
||||
// Import all table schema definitions
|
||||
import ClientAppConfigRow from "./client_app_config_table";
|
||||
import KickEventRow from "./kick_event_table";
|
||||
import MyAuthAuditLogsRow from "./my_auth_audit_logs_table";
|
||||
import MyAuthRiskBlocksRow from "./my_auth_risk_blocks_table";
|
||||
import MyAuthStateRow from "./my_auth_state_table";
|
||||
import MyBrowseHistoryRow from "./my_browse_history_table";
|
||||
import MyCustomWorldProfilesRow from "./my_custom_world_profiles_table";
|
||||
import MyCustomWorldSessionsRow from "./my_custom_world_sessions_table";
|
||||
import MyProfileDashboardRow from "./my_profile_dashboard_table";
|
||||
import MyProfilePlayedWorldsRow from "./my_profile_played_worlds_table";
|
||||
import MyProfileWalletLedgerRow from "./my_profile_wallet_ledger_table";
|
||||
import MyRuntimeSettingsRow from "./my_runtime_settings_table";
|
||||
import MySnapshotRow from "./my_snapshot_table";
|
||||
import MyUserSessionsRow from "./my_user_sessions_table";
|
||||
import PublishedCustomWorldGalleryRow from "./published_custom_world_gallery_table";
|
||||
import PublishedCustomWorldProfilesRow from "./published_custom_world_profiles_table";
|
||||
import SessionRevocationEventRow from "./session_revocation_event_table";
|
||||
import VerificationPromptEventRow from "./verification_prompt_event_table";
|
||||
|
||||
/** Type-only namespace exports for generated type groups. */
|
||||
|
||||
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
|
||||
const tablesSchema = __schema({
|
||||
kick_event: __table({
|
||||
name: 'kick_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, KickEventRow),
|
||||
session_revocation_event: __table({
|
||||
name: 'session_revocation_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, SessionRevocationEventRow),
|
||||
verification_prompt_event: __table({
|
||||
name: 'verification_prompt_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, VerificationPromptEventRow),
|
||||
client_app_config: __table({
|
||||
name: 'client_app_config',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, ClientAppConfigRow),
|
||||
my_auth_audit_logs: __table({
|
||||
name: 'my_auth_audit_logs',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthAuditLogsRow),
|
||||
my_auth_risk_blocks: __table({
|
||||
name: 'my_auth_risk_blocks',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthRiskBlocksRow),
|
||||
my_auth_state: __table({
|
||||
name: 'my_auth_state',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthStateRow),
|
||||
my_browse_history: __table({
|
||||
name: 'my_browse_history',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyBrowseHistoryRow),
|
||||
my_custom_world_profiles: __table({
|
||||
name: 'my_custom_world_profiles',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyCustomWorldProfilesRow),
|
||||
my_custom_world_sessions: __table({
|
||||
name: 'my_custom_world_sessions',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyCustomWorldSessionsRow),
|
||||
my_profile_dashboard: __table({
|
||||
name: 'my_profile_dashboard',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfileDashboardRow),
|
||||
my_profile_played_worlds: __table({
|
||||
name: 'my_profile_played_worlds',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfilePlayedWorldsRow),
|
||||
my_profile_wallet_ledger: __table({
|
||||
name: 'my_profile_wallet_ledger',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfileWalletLedgerRow),
|
||||
my_runtime_settings: __table({
|
||||
name: 'my_runtime_settings',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyRuntimeSettingsRow),
|
||||
my_snapshot: __table({
|
||||
name: 'my_snapshot',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MySnapshotRow),
|
||||
my_user_sessions: __table({
|
||||
name: 'my_user_sessions',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyUserSessionsRow),
|
||||
published_custom_world_gallery: __table({
|
||||
name: 'published_custom_world_gallery',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, PublishedCustomWorldGalleryRow),
|
||||
published_custom_world_profiles: __table({
|
||||
name: 'published_custom_world_profiles',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, PublishedCustomWorldProfilesRow),
|
||||
});
|
||||
|
||||
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
|
||||
const reducersSchema = __reducers(
|
||||
);
|
||||
|
||||
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
|
||||
const proceduresSchema = __procedures(
|
||||
__procedureSchema("clear_platform_browse_history", ClearPlatformBrowseHistoryProcedure.params, ClearPlatformBrowseHistoryProcedure.returnType),
|
||||
__procedureSchema("delete_custom_world_profile", DeleteCustomWorldProfileProcedure.params, DeleteCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("delete_snapshot", DeleteSnapshotProcedure.params, DeleteSnapshotProcedure.returnType),
|
||||
__procedureSchema("lift_my_risk_block", LiftMyRiskBlockProcedure.params, LiftMyRiskBlockProcedure.returnType),
|
||||
__procedureSchema("logout_all_user_sessions", LogoutAllUserSessionsProcedure.params, LogoutAllUserSessionsProcedure.returnType),
|
||||
__procedureSchema("publish_custom_world_profile", PublishCustomWorldProfileProcedure.params, PublishCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("put_runtime_settings", PutRuntimeSettingsProcedure.params, PutRuntimeSettingsProcedure.returnType),
|
||||
__procedureSchema("revoke_user_session", RevokeUserSessionProcedure.params, RevokeUserSessionProcedure.returnType),
|
||||
__procedureSchema("save_snapshot", SaveSnapshotProcedure.params, SaveSnapshotProcedure.returnType),
|
||||
__procedureSchema("send_sms_verification_code", SendSmsVerificationCodeProcedure.params, SendSmsVerificationCodeProcedure.returnType),
|
||||
__procedureSchema("unpublish_custom_world_profile", UnpublishCustomWorldProfileProcedure.params, UnpublishCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("upsert_custom_world_profile", UpsertCustomWorldProfileProcedure.params, UpsertCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("upsert_custom_world_session", UpsertCustomWorldSessionProcedure.params, UpsertCustomWorldSessionProcedure.returnType),
|
||||
__procedureSchema("upsert_platform_browse_history", UpsertPlatformBrowseHistoryProcedure.params, UpsertPlatformBrowseHistoryProcedure.returnType),
|
||||
__procedureSchema("verify_sms_code", VerifySmsCodeProcedure.params, VerifySmsCodeProcedure.returnType),
|
||||
);
|
||||
|
||||
/** The remote SpacetimeDB module schema, both runtime and type information. */
|
||||
const REMOTE_MODULE = {
|
||||
versionInfo: {
|
||||
cliVersion: "2.1.0" as const,
|
||||
},
|
||||
tables: tablesSchema.schemaType.tables,
|
||||
reducers: reducersSchema.reducersType.reducers,
|
||||
...proceduresSchema,
|
||||
} satisfies __RemoteModule<
|
||||
typeof tablesSchema.schemaType,
|
||||
typeof reducersSchema.reducersType,
|
||||
typeof proceduresSchema
|
||||
>;
|
||||
|
||||
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
|
||||
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
|
||||
|
||||
/** The reducers available in this remote SpacetimeDB module. */
|
||||
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
|
||||
|
||||
/** The context type returned in callbacks for all possible events. */
|
||||
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for reducer events. */
|
||||
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for subscription events. */
|
||||
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for error events. */
|
||||
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
|
||||
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
|
||||
|
||||
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
|
||||
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
|
||||
|
||||
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
|
||||
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||
|
||||
/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
|
||||
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
|
||||
static builder = (): DbConnectionBuilder => {
|
||||
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
|
||||
};
|
||||
|
||||
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
|
||||
override subscriptionBuilder = (): SubscriptionBuilder => {
|
||||
return new SubscriptionBuilder(this);
|
||||
};
|
||||
}
|
||||
|
||||
18
src/spacetime/generated/kick_event_table.ts
Normal file
18
src/spacetime/generated/kick_event_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetIdentity: __t.identity().name("target_identity"),
|
||||
reasonCode: __t.string().name("reason_code"),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
27
src/spacetime/generated/lift_my_risk_block_procedure.ts
Normal file
27
src/spacetime/generated/lift_my_risk_block_procedure.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RiskBlockScopeType,
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
20
src/spacetime/generated/my_auth_audit_logs_table.ts
Normal file
20
src/spacetime/generated/my_auth_audit_logs_table.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.u64(),
|
||||
eventType: __t.string().name("event_type"),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()).name("user_agent"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
});
|
||||
24
src/spacetime/generated/my_auth_risk_blocks_table.ts
Normal file
24
src/spacetime/generated/my_auth_risk_blocks_table.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
RiskBlockScopeType,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType.name("scope_type");
|
||||
},
|
||||
scopeKey: __t.string().name("scope_key"),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64().name("expires_at_ms"),
|
||||
});
|
||||
32
src/spacetime/generated/my_auth_state_table.ts
Normal file
32
src/spacetime/generated/my_auth_state_table.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
LoginProvider,
|
||||
AccountStatus,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
userId: __t.string().name("user_id"),
|
||||
identity: __t.identity(),
|
||||
displayName: __t.string().name("display_name"),
|
||||
phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"),
|
||||
get loginProvider() {
|
||||
return LoginProvider.name("login_provider");
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus.name("account_status");
|
||||
},
|
||||
smsVerificationRequired: __t.bool().name("sms_verification_required"),
|
||||
smsVerified: __t.bool().name("sms_verified"),
|
||||
jwtPresent: __t.bool().name("jwt_present"),
|
||||
});
|
||||
29
src/spacetime/generated/my_browse_history_table.ts
Normal file
29
src/spacetime/generated/my_browse_history_table.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
visitedAtMs: __t.u64().name("visited_at_ms"),
|
||||
});
|
||||
37
src/spacetime/generated/my_custom_world_profiles_table.ts
Normal file
37
src/spacetime/generated/my_custom_world_profiles_table.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
18
src/spacetime/generated/my_custom_world_sessions_table.ts
Normal file
18
src/spacetime/generated/my_custom_world_sessions_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
sessionId: __t.string().name("session_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
});
|
||||
18
src/spacetime/generated/my_profile_dashboard_table.ts
Normal file
18
src/spacetime/generated/my_profile_dashboard_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
walletBalance: __t.i64().name("wallet_balance"),
|
||||
totalPlayTimeMs: __t.u64().name("total_play_time_ms"),
|
||||
playedWorldCount: __t.u32().name("played_world_count"),
|
||||
updatedAtMs: __t.option(__t.u64()).name("updated_at_ms"),
|
||||
});
|
||||
23
src/spacetime/generated/my_profile_played_worlds_table.ts
Normal file
23
src/spacetime/generated/my_profile_played_worlds_table.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
worldKey: __t.string().name("world_key"),
|
||||
ownerUserId: __t.option(__t.string()).name("owner_user_id"),
|
||||
profileId: __t.option(__t.string()).name("profile_id"),
|
||||
worldType: __t.option(__t.string()).name("world_type"),
|
||||
worldTitle: __t.string().name("world_title"),
|
||||
worldSubtitle: __t.string().name("world_subtitle"),
|
||||
firstPlayedAtMs: __t.u64().name("first_played_at_ms"),
|
||||
lastPlayedAtMs: __t.u64().name("last_played_at_ms"),
|
||||
lastObservedPlayTimeMs: __t.u64().name("last_observed_play_time_ms"),
|
||||
});
|
||||
19
src/spacetime/generated/my_profile_wallet_ledger_table.ts
Normal file
19
src/spacetime/generated/my_profile_wallet_ledger_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.string(),
|
||||
amountDelta: __t.i64().name("amount_delta"),
|
||||
balanceAfter: __t.i64().name("balance_after"),
|
||||
sourceType: __t.string().name("source_type"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
});
|
||||
15
src/spacetime/generated/my_runtime_settings_table.ts
Normal file
15
src/spacetime/generated/my_runtime_settings_table.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
musicVolume: __t.f32().name("music_volume"),
|
||||
});
|
||||
19
src/spacetime/generated/my_snapshot_table.ts
Normal file
19
src/spacetime/generated/my_snapshot_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64().name("saved_at_ms"),
|
||||
gameStateJson: __t.string().name("game_state_json"),
|
||||
bottomTab: __t.string().name("bottom_tab"),
|
||||
currentStoryJson: __t.option(__t.string()).name("current_story_json"),
|
||||
});
|
||||
22
src/spacetime/generated/my_user_sessions_table.ts
Normal file
22
src/spacetime/generated/my_user_sessions_table.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
sessionId: __t.string().name("session_id"),
|
||||
clientType: __t.string().name("client_type"),
|
||||
userAgent: __t.option(__t.string()).name("user_agent"),
|
||||
ip: __t.option(__t.string()),
|
||||
isCurrent: __t.bool().name("is_current"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
lastSeenAtMs: __t.u64().name("last_seen_at_ms"),
|
||||
expiresAtMs: __t.option(__t.u64()).name("expires_at_ms"),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,36 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
24
src/spacetime/generated/put_runtime_settings_procedure.ts
Normal file
24
src/spacetime/generated/put_runtime_settings_procedure.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
musicVolume: __t.f32(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
24
src/spacetime/generated/revoke_user_session_procedure.ts
Normal file
24
src/spacetime/generated/revoke_user_session_procedure.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
sessionId: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
27
src/spacetime/generated/save_snapshot_procedure.ts
Normal file
27
src/spacetime/generated/save_snapshot_procedure.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
savedAtMs: __t.u64(),
|
||||
gameStateJson: __t.string(),
|
||||
bottomTab: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,28 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
SmsSendCodeResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
phoneNumber: __t.string(),
|
||||
get scene() {
|
||||
return SmsAuthScene;
|
||||
},
|
||||
};
|
||||
export const returnType = SmsSendCodeResult
|
||||
18
src/spacetime/generated/session_revocation_event_table.ts
Normal file
18
src/spacetime/generated/session_revocation_event_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetSessionId: __t.string().name("target_session_id"),
|
||||
reasonCode: __t.string().name("reason_code"),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
600
src/spacetime/generated/types.ts
Normal file
600
src/spacetime/generated/types.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
// The tagged union or sum type for the algebraic type `AccountStatus`.
|
||||
export const AccountStatus = __t.enum("AccountStatus", {
|
||||
Active: __t.unit(),
|
||||
PendingSmsVerification: __t.unit(),
|
||||
Disabled: __t.unit(),
|
||||
});
|
||||
export type AccountStatus = __Infer<typeof AccountStatus>;
|
||||
|
||||
export const AppConfig = __t.object("AppConfig", {
|
||||
id: __t.u8(),
|
||||
guestLoginEnabled: __t.bool(),
|
||||
smsAuthEnabled: __t.bool(),
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsProvider: __t.string(),
|
||||
smsEndpoint: __t.string(),
|
||||
smsAccessKeyId: __t.string(),
|
||||
smsAccessKeySecret: __t.string(),
|
||||
smsSignName: __t.string(),
|
||||
smsTemplateCode: __t.string(),
|
||||
smsTemplateParamKey: __t.string(),
|
||||
smsCountryCode: __t.string(),
|
||||
smsSchemeName: __t.string(),
|
||||
smsCodeLength: __t.u16(),
|
||||
smsCodeType: __t.u16(),
|
||||
smsValidTimeSeconds: __t.u32(),
|
||||
smsIntervalSeconds: __t.u32(),
|
||||
smsDuplicatePolicy: __t.u16(),
|
||||
smsCaseAuthPolicy: __t.u16(),
|
||||
smsReturnVerifyCode: __t.bool(),
|
||||
smsMockVerifyCode: __t.string(),
|
||||
smsMaxSendPerPhonePerDay: __t.u16(),
|
||||
smsMaxSendPerIpPerHour: __t.u16(),
|
||||
smsMaxVerifyFailuresPerPhonePerHour: __t.u16(),
|
||||
smsMaxVerifyFailuresPerIpPerHour: __t.u16(),
|
||||
smsCaptchaTtlSeconds: __t.u32(),
|
||||
smsCaptchaTriggerVerifyFailuresPerPhone: __t.u16(),
|
||||
smsCaptchaTriggerVerifyFailuresPerIp: __t.u16(),
|
||||
smsBlockPhoneFailureThreshold: __t.u16(),
|
||||
smsBlockIpFailureThreshold: __t.u16(),
|
||||
smsBlockPhoneDurationMinutes: __t.u16(),
|
||||
smsBlockIpDurationMinutes: __t.u16(),
|
||||
defaultMusicVolume: __t.f32(),
|
||||
defaultGuestDisplayNamePrefix: __t.string(),
|
||||
kickMessageUnverified: __t.string(),
|
||||
wechatEnabled: __t.bool(),
|
||||
wechatProvider: __t.string(),
|
||||
wechatAppId: __t.string(),
|
||||
wechatAppSecret: __t.string(),
|
||||
wechatAuthorizeEndpoint: __t.string(),
|
||||
wechatAccessTokenEndpoint: __t.string(),
|
||||
wechatUserInfoEndpoint: __t.string(),
|
||||
wechatCallbackPath: __t.string(),
|
||||
wechatDefaultRedirectPath: __t.string(),
|
||||
wechatMockUserId: __t.string(),
|
||||
wechatMockUnionId: __t.string(),
|
||||
wechatMockDisplayName: __t.string(),
|
||||
wechatMockAvatarUrl: __t.string(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AppConfig = __Infer<typeof AppConfig>;
|
||||
|
||||
export const AuthAuditLog = __t.object("AuthAuditLog", {
|
||||
id: __t.u64(),
|
||||
userId: __t.string(),
|
||||
eventType: __t.string(),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()),
|
||||
metaJson: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthAuditLog = __Infer<typeof AuthAuditLog>;
|
||||
|
||||
export const AuthAuditLogView = __t.object("AuthAuditLogView", {
|
||||
id: __t.u64(),
|
||||
eventType: __t.string(),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthAuditLogView = __Infer<typeof AuthAuditLogView>;
|
||||
|
||||
export const AuthIdentity = __t.object("AuthIdentity", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
get provider() {
|
||||
return AuthIdentityProvider;
|
||||
},
|
||||
providerUid: __t.string(),
|
||||
providerUnionId: __t.option(__t.string()),
|
||||
displayName: __t.option(__t.string()),
|
||||
avatarUrl: __t.option(__t.string()),
|
||||
isVerified: __t.bool(),
|
||||
metaJson: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthIdentity = __Infer<typeof AuthIdentity>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `AuthIdentityProvider`.
|
||||
export const AuthIdentityProvider = __t.enum("AuthIdentityProvider", {
|
||||
Guest: __t.unit(),
|
||||
Jwt: __t.unit(),
|
||||
Phone: __t.unit(),
|
||||
Wechat: __t.unit(),
|
||||
});
|
||||
export type AuthIdentityProvider = __Infer<typeof AuthIdentityProvider>;
|
||||
|
||||
export const AuthRiskBlock = __t.object("AuthRiskBlock", {
|
||||
id: __t.u64(),
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
scopeKey: __t.string(),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64(),
|
||||
liftedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthRiskBlock = __Infer<typeof AuthRiskBlock>;
|
||||
|
||||
export const AuthRiskBlockView = __t.object("AuthRiskBlockView", {
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
scopeKey: __t.string(),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthRiskBlockView = __Infer<typeof AuthRiskBlockView>;
|
||||
|
||||
export const AuthSessionView = __t.object("AuthSessionView", {
|
||||
sessionId: __t.string(),
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
isCurrent: __t.bool(),
|
||||
createdAtMs: __t.u64(),
|
||||
lastSeenAtMs: __t.u64(),
|
||||
expiresAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type AuthSessionView = __Infer<typeof AuthSessionView>;
|
||||
|
||||
export const AuthStateView = __t.object("AuthStateView", {
|
||||
userId: __t.string(),
|
||||
identity: __t.identity(),
|
||||
displayName: __t.string(),
|
||||
phoneNumberMasked: __t.option(__t.string()),
|
||||
get loginProvider() {
|
||||
return LoginProvider;
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus;
|
||||
},
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsVerified: __t.bool(),
|
||||
jwtPresent: __t.bool(),
|
||||
});
|
||||
export type AuthStateView = __Infer<typeof AuthStateView>;
|
||||
|
||||
export const ClientAppConfigView = __t.object("ClientAppConfigView", {
|
||||
guestLoginEnabled: __t.bool(),
|
||||
smsAuthEnabled: __t.bool(),
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsProvider: __t.string(),
|
||||
smsCodeLength: __t.u16(),
|
||||
smsValidTimeSeconds: __t.u32(),
|
||||
smsIntervalSeconds: __t.u32(),
|
||||
defaultMusicVolume: __t.f32(),
|
||||
defaultGuestDisplayNamePrefix: __t.string(),
|
||||
wechatEnabled: __t.bool(),
|
||||
});
|
||||
export type ClientAppConfigView = __Infer<typeof ClientAppConfigView>;
|
||||
|
||||
export const CustomWorldGalleryCardView = __t.object("CustomWorldGalleryCardView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type CustomWorldGalleryCardView = __Infer<typeof CustomWorldGalleryCardView>;
|
||||
|
||||
export const CustomWorldProfile = __t.object("CustomWorldProfile", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
payloadJson: __t.string(),
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
deletedAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type CustomWorldProfile = __Infer<typeof CustomWorldProfile>;
|
||||
|
||||
export const CustomWorldProfileView = __t.object("CustomWorldProfileView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type CustomWorldProfileView = __Infer<typeof CustomWorldProfileView>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `CustomWorldPublicationStatus`.
|
||||
export const CustomWorldPublicationStatus = __t.enum("CustomWorldPublicationStatus", {
|
||||
Draft: __t.unit(),
|
||||
Published: __t.unit(),
|
||||
});
|
||||
export type CustomWorldPublicationStatus = __Infer<typeof CustomWorldPublicationStatus>;
|
||||
|
||||
export const CustomWorldSession = __t.object("CustomWorldSession", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type CustomWorldSession = __Infer<typeof CustomWorldSession>;
|
||||
|
||||
export const CustomWorldSessionView = __t.object("CustomWorldSessionView", {
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type CustomWorldSessionView = __Infer<typeof CustomWorldSessionView>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `CustomWorldThemeMode`.
|
||||
export const CustomWorldThemeMode = __t.enum("CustomWorldThemeMode", {
|
||||
Martial: __t.unit(),
|
||||
Arcane: __t.unit(),
|
||||
Machina: __t.unit(),
|
||||
Tide: __t.unit(),
|
||||
Rift: __t.unit(),
|
||||
Mythic: __t.unit(),
|
||||
});
|
||||
export type CustomWorldThemeMode = __Infer<typeof CustomWorldThemeMode>;
|
||||
|
||||
export const KickEvent = __t.object("KickEvent", {
|
||||
targetIdentity: __t.identity(),
|
||||
reasonCode: __t.string(),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type KickEvent = __Infer<typeof KickEvent>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `LoginProvider`.
|
||||
export const LoginProvider = __t.enum("LoginProvider", {
|
||||
Guest: __t.unit(),
|
||||
Jwt: __t.unit(),
|
||||
Phone: __t.unit(),
|
||||
Wechat: __t.unit(),
|
||||
});
|
||||
export type LoginProvider = __Infer<typeof LoginProvider>;
|
||||
|
||||
export const MutationResult = __t.object("MutationResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
});
|
||||
export type MutationResult = __Infer<typeof MutationResult>;
|
||||
|
||||
export const PlatformBrowseHistoryView = __t.object("PlatformBrowseHistoryView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type PlatformBrowseHistoryView = __Infer<typeof PlatformBrowseHistoryView>;
|
||||
|
||||
export const PlatformBrowseHistoryWriteInput = __t.object("PlatformBrowseHistoryWriteInput", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type PlatformBrowseHistoryWriteInput = __Infer<typeof PlatformBrowseHistoryWriteInput>;
|
||||
|
||||
export const ProfileDashboardState = __t.object("ProfileDashboardState", {
|
||||
userId: __t.string(),
|
||||
walletBalance: __t.i64(),
|
||||
totalPlayTimeMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileDashboardState = __Infer<typeof ProfileDashboardState>;
|
||||
|
||||
export const ProfileDashboardView = __t.object("ProfileDashboardView", {
|
||||
walletBalance: __t.i64(),
|
||||
totalPlayTimeMs: __t.u64(),
|
||||
playedWorldCount: __t.u32(),
|
||||
updatedAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type ProfileDashboardView = __Infer<typeof ProfileDashboardView>;
|
||||
|
||||
export const ProfilePlayedWorld = __t.object("ProfilePlayedWorld", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
worldKey: __t.string(),
|
||||
ownerUserId: __t.option(__t.string()),
|
||||
profileId: __t.option(__t.string()),
|
||||
worldType: __t.option(__t.string()),
|
||||
worldTitle: __t.string(),
|
||||
worldSubtitle: __t.string(),
|
||||
firstPlayedAtMs: __t.u64(),
|
||||
lastPlayedAtMs: __t.u64(),
|
||||
lastObservedPlayTimeMs: __t.u64(),
|
||||
});
|
||||
export type ProfilePlayedWorld = __Infer<typeof ProfilePlayedWorld>;
|
||||
|
||||
export const ProfilePlayedWorldView = __t.object("ProfilePlayedWorldView", {
|
||||
worldKey: __t.string(),
|
||||
ownerUserId: __t.option(__t.string()),
|
||||
profileId: __t.option(__t.string()),
|
||||
worldType: __t.option(__t.string()),
|
||||
worldTitle: __t.string(),
|
||||
worldSubtitle: __t.string(),
|
||||
firstPlayedAtMs: __t.u64(),
|
||||
lastPlayedAtMs: __t.u64(),
|
||||
lastObservedPlayTimeMs: __t.u64(),
|
||||
});
|
||||
export type ProfilePlayedWorldView = __Infer<typeof ProfilePlayedWorldView>;
|
||||
|
||||
export const ProfileWalletLedger = __t.object("ProfileWalletLedger", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
amountDelta: __t.i64(),
|
||||
balanceAfter: __t.i64(),
|
||||
sourceType: __t.string(),
|
||||
sourceKey: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileWalletLedger = __Infer<typeof ProfileWalletLedger>;
|
||||
|
||||
export const ProfileWalletLedgerView = __t.object("ProfileWalletLedgerView", {
|
||||
id: __t.string(),
|
||||
amountDelta: __t.i64(),
|
||||
balanceAfter: __t.i64(),
|
||||
sourceType: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileWalletLedgerView = __Infer<typeof ProfileWalletLedgerView>;
|
||||
|
||||
export const PublishedCustomWorldProfileView = __t.object("PublishedCustomWorldProfileView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type PublishedCustomWorldProfileView = __Infer<typeof PublishedCustomWorldProfileView>;
|
||||
|
||||
export const RequestMeta = __t.object("RequestMeta", {
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
});
|
||||
export type RequestMeta = __Infer<typeof RequestMeta>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `RiskBlockScopeType`.
|
||||
export const RiskBlockScopeType = __t.enum("RiskBlockScopeType", {
|
||||
Phone: __t.unit(),
|
||||
Ip: __t.unit(),
|
||||
});
|
||||
export type RiskBlockScopeType = __Infer<typeof RiskBlockScopeType>;
|
||||
|
||||
export const RuntimeSetting = __t.object("RuntimeSetting", {
|
||||
userId: __t.string(),
|
||||
musicVolume: __t.f32(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type RuntimeSetting = __Infer<typeof RuntimeSetting>;
|
||||
|
||||
export const RuntimeSettingsView = __t.object("RuntimeSettingsView", {
|
||||
musicVolume: __t.f32(),
|
||||
});
|
||||
export type RuntimeSettingsView = __Infer<typeof RuntimeSettingsView>;
|
||||
|
||||
export const SaveSnapshot = __t.object("SaveSnapshot", {
|
||||
userId: __t.string(),
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64(),
|
||||
bottomTab: __t.string(),
|
||||
gameStateJson: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type SaveSnapshot = __Infer<typeof SaveSnapshot>;
|
||||
|
||||
export const SessionRevocationEventRow = __t.object("SessionRevocationEventRow", {
|
||||
targetSessionId: __t.string(),
|
||||
reasonCode: __t.string(),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type SessionRevocationEventRow = __Infer<typeof SessionRevocationEventRow>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `SmsAuthAction`.
|
||||
export const SmsAuthAction = __t.enum("SmsAuthAction", {
|
||||
SendCode: __t.unit(),
|
||||
VerifyCode: __t.unit(),
|
||||
});
|
||||
export type SmsAuthAction = __Infer<typeof SmsAuthAction>;
|
||||
|
||||
export const SmsAuthEvent = __t.object("SmsAuthEvent", {
|
||||
id: __t.u64(),
|
||||
identity: __t.identity(),
|
||||
phoneNumber: __t.string(),
|
||||
get scene() {
|
||||
return SmsAuthScene;
|
||||
},
|
||||
get action() {
|
||||
return SmsAuthAction;
|
||||
},
|
||||
success: __t.bool(),
|
||||
ip: __t.option(__t.string()),
|
||||
ipKey: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type SmsAuthEvent = __Infer<typeof SmsAuthEvent>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `SmsAuthScene`.
|
||||
export const SmsAuthScene = __t.enum("SmsAuthScene", {
|
||||
Login: __t.unit(),
|
||||
BindPhone: __t.unit(),
|
||||
ChangePhone: __t.unit(),
|
||||
});
|
||||
export type SmsAuthScene = __Infer<typeof SmsAuthScene>;
|
||||
|
||||
export const SmsSendCodeResult = __t.object("SmsSendCodeResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
cooldownSeconds: __t.u32(),
|
||||
expiresInSeconds: __t.u32(),
|
||||
providerRequestId: __t.option(__t.string()),
|
||||
});
|
||||
export type SmsSendCodeResult = __Infer<typeof SmsSendCodeResult>;
|
||||
|
||||
export const SmsVerifyCodeResult = __t.object("SmsVerifyCodeResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
verified: __t.bool(),
|
||||
});
|
||||
export type SmsVerifyCodeResult = __Infer<typeof SmsVerifyCodeResult>;
|
||||
|
||||
export const SnapshotView = __t.object("SnapshotView", {
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64(),
|
||||
gameStateJson: __t.string(),
|
||||
bottomTab: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
});
|
||||
export type SnapshotView = __Infer<typeof SnapshotView>;
|
||||
|
||||
export const User = __t.object("User", {
|
||||
id: __t.string(),
|
||||
identity: __t.identity(),
|
||||
username: __t.option(__t.string()),
|
||||
passwordHash: __t.option(__t.string()),
|
||||
tokenVersion: __t.u32(),
|
||||
displayName: __t.string(),
|
||||
get loginProvider() {
|
||||
return LoginProvider;
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus;
|
||||
},
|
||||
phoneNumber: __t.option(__t.string()),
|
||||
phoneVerifiedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type User = __Infer<typeof User>;
|
||||
|
||||
export const UserBrowseHistory = __t.object("UserBrowseHistory", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type UserBrowseHistory = __Infer<typeof UserBrowseHistory>;
|
||||
|
||||
export const UserSession = __t.object("UserSession", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
refreshTokenHash: __t.string(),
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
expiresAtMs: __t.option(__t.u64()),
|
||||
revokedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
lastSeenAtMs: __t.u64(),
|
||||
});
|
||||
export type UserSession = __Infer<typeof UserSession>;
|
||||
|
||||
export const VerificationPromptEvent = __t.object("VerificationPromptEvent", {
|
||||
targetIdentity: __t.identity(),
|
||||
phoneNumberMasked: __t.option(__t.string()),
|
||||
title: __t.string(),
|
||||
detail: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type VerificationPromptEvent = __Infer<typeof VerificationPromptEvent>;
|
||||
|
||||
55
src/spacetime/generated/types/procedures.ts
Normal file
55
src/spacetime/generated/types/procedures.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
import * as ClearPlatformBrowseHistoryProcedure from "../clear_platform_browse_history_procedure";
|
||||
import * as DeleteCustomWorldProfileProcedure from "../delete_custom_world_profile_procedure";
|
||||
import * as DeleteSnapshotProcedure from "../delete_snapshot_procedure";
|
||||
import * as LiftMyRiskBlockProcedure from "../lift_my_risk_block_procedure";
|
||||
import * as LogoutAllUserSessionsProcedure from "../logout_all_user_sessions_procedure";
|
||||
import * as PublishCustomWorldProfileProcedure from "../publish_custom_world_profile_procedure";
|
||||
import * as PutRuntimeSettingsProcedure from "../put_runtime_settings_procedure";
|
||||
import * as RevokeUserSessionProcedure from "../revoke_user_session_procedure";
|
||||
import * as SaveSnapshotProcedure from "../save_snapshot_procedure";
|
||||
import * as SendSmsVerificationCodeProcedure from "../send_sms_verification_code_procedure";
|
||||
import * as UnpublishCustomWorldProfileProcedure from "../unpublish_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldProfileProcedure from "../upsert_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldSessionProcedure from "../upsert_custom_world_session_procedure";
|
||||
import * as UpsertPlatformBrowseHistoryProcedure from "../upsert_platform_browse_history_procedure";
|
||||
import * as VerifySmsCodeProcedure from "../verify_sms_code_procedure";
|
||||
|
||||
export type ClearPlatformBrowseHistoryArgs = __Infer<typeof ClearPlatformBrowseHistoryProcedure.params>;
|
||||
export type ClearPlatformBrowseHistoryResult = __Infer<typeof ClearPlatformBrowseHistoryProcedure.returnType>;
|
||||
export type DeleteCustomWorldProfileArgs = __Infer<typeof DeleteCustomWorldProfileProcedure.params>;
|
||||
export type DeleteCustomWorldProfileResult = __Infer<typeof DeleteCustomWorldProfileProcedure.returnType>;
|
||||
export type DeleteSnapshotArgs = __Infer<typeof DeleteSnapshotProcedure.params>;
|
||||
export type DeleteSnapshotResult = __Infer<typeof DeleteSnapshotProcedure.returnType>;
|
||||
export type LiftMyRiskBlockArgs = __Infer<typeof LiftMyRiskBlockProcedure.params>;
|
||||
export type LiftMyRiskBlockResult = __Infer<typeof LiftMyRiskBlockProcedure.returnType>;
|
||||
export type LogoutAllUserSessionsArgs = __Infer<typeof LogoutAllUserSessionsProcedure.params>;
|
||||
export type LogoutAllUserSessionsResult = __Infer<typeof LogoutAllUserSessionsProcedure.returnType>;
|
||||
export type PublishCustomWorldProfileArgs = __Infer<typeof PublishCustomWorldProfileProcedure.params>;
|
||||
export type PublishCustomWorldProfileResult = __Infer<typeof PublishCustomWorldProfileProcedure.returnType>;
|
||||
export type PutRuntimeSettingsArgs = __Infer<typeof PutRuntimeSettingsProcedure.params>;
|
||||
export type PutRuntimeSettingsResult = __Infer<typeof PutRuntimeSettingsProcedure.returnType>;
|
||||
export type RevokeUserSessionArgs = __Infer<typeof RevokeUserSessionProcedure.params>;
|
||||
export type RevokeUserSessionResult = __Infer<typeof RevokeUserSessionProcedure.returnType>;
|
||||
export type SaveSnapshotArgs = __Infer<typeof SaveSnapshotProcedure.params>;
|
||||
export type SaveSnapshotResult = __Infer<typeof SaveSnapshotProcedure.returnType>;
|
||||
export type SendSmsVerificationCodeArgs = __Infer<typeof SendSmsVerificationCodeProcedure.params>;
|
||||
export type SendSmsVerificationCodeResult = __Infer<typeof SendSmsVerificationCodeProcedure.returnType>;
|
||||
export type UnpublishCustomWorldProfileArgs = __Infer<typeof UnpublishCustomWorldProfileProcedure.params>;
|
||||
export type UnpublishCustomWorldProfileResult = __Infer<typeof UnpublishCustomWorldProfileProcedure.returnType>;
|
||||
export type UpsertCustomWorldProfileArgs = __Infer<typeof UpsertCustomWorldProfileProcedure.params>;
|
||||
export type UpsertCustomWorldProfileResult = __Infer<typeof UpsertCustomWorldProfileProcedure.returnType>;
|
||||
export type UpsertCustomWorldSessionArgs = __Infer<typeof UpsertCustomWorldSessionProcedure.params>;
|
||||
export type UpsertCustomWorldSessionResult = __Infer<typeof UpsertCustomWorldSessionProcedure.returnType>;
|
||||
export type UpsertPlatformBrowseHistoryArgs = __Infer<typeof UpsertPlatformBrowseHistoryProcedure.params>;
|
||||
export type UpsertPlatformBrowseHistoryResult = __Infer<typeof UpsertPlatformBrowseHistoryProcedure.returnType>;
|
||||
export type VerifySmsCodeArgs = __Infer<typeof VerifySmsCodeProcedure.params>;
|
||||
export type VerifySmsCodeResult = __Infer<typeof VerifySmsCodeProcedure.returnType>;
|
||||
|
||||
10
src/spacetime/generated/types/reducers.ts
Normal file
10
src/spacetime/generated/types/reducers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,26 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
PlatformBrowseHistoryWriteInput,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
get entries() {
|
||||
return __t.array(PlatformBrowseHistoryWriteInput);
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
19
src/spacetime/generated/verification_prompt_event_table.ts
Normal file
19
src/spacetime/generated/verification_prompt_event_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetIdentity: __t.identity().name("target_identity"),
|
||||
phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"),
|
||||
title: __t.string(),
|
||||
detail: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
25
src/spacetime/generated/verify_sms_code_procedure.ts
Normal file
25
src/spacetime/generated/verify_sms_code_procedure.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
SmsVerifyCodeResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
phoneNumber: __t.string(),
|
||||
code: __t.string(),
|
||||
};
|
||||
export const returnType = SmsVerifyCodeResult
|
||||
387
src/spacetime/mappers.ts
Normal file
387
src/spacetime/mappers.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthBindingStatus,
|
||||
AuthLoginMethod,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import type {
|
||||
AuthAuditLogView,
|
||||
AuthRiskBlockView,
|
||||
AuthStateView,
|
||||
AuthSessionView,
|
||||
ClientAppConfigView,
|
||||
CustomWorldGalleryCardView,
|
||||
CustomWorldProfileView,
|
||||
CustomWorldSessionView,
|
||||
PlatformBrowseHistoryView,
|
||||
ProfileDashboardView,
|
||||
ProfilePlayedWorldView,
|
||||
ProfileWalletLedgerView,
|
||||
PublishedCustomWorldProfileView,
|
||||
RuntimeSettingsView,
|
||||
SnapshotView,
|
||||
} from './generated/types';
|
||||
|
||||
function bigintToNumber(value: bigint | number | null | undefined) {
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
|
||||
function bigintToIso(value: bigint | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(bigintToNumber(value)).toISOString();
|
||||
}
|
||||
|
||||
function enumTag(value: { tag: string } | null | undefined) {
|
||||
return value?.tag ?? '';
|
||||
}
|
||||
|
||||
function mapLoginMethod(tag: string): AuthLoginMethod {
|
||||
switch (tag) {
|
||||
case 'Phone':
|
||||
return 'phone';
|
||||
case 'Wechat':
|
||||
return 'wechat';
|
||||
case 'Jwt':
|
||||
return 'jwt';
|
||||
case 'Guest':
|
||||
return 'guest';
|
||||
default:
|
||||
return 'guest';
|
||||
}
|
||||
}
|
||||
|
||||
function mapBindingStatus(row: AuthStateView): AuthBindingStatus {
|
||||
return row.smsVerificationRequired && !row.smsVerified
|
||||
? 'pending_bind_phone'
|
||||
: 'active';
|
||||
}
|
||||
|
||||
export function mapAuthUser(row: AuthStateView): AuthUser {
|
||||
return {
|
||||
id: row.userId,
|
||||
username: row.displayName,
|
||||
displayName: row.displayName,
|
||||
phoneNumberMasked: row.phoneNumberMasked ?? null,
|
||||
loginMethod: mapLoginMethod(enumTag(row.loginProvider)),
|
||||
bindingStatus: mapBindingStatus(row),
|
||||
wechatBound: enumTag(row.loginProvider) === 'Wechat',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAvailableLoginMethods(
|
||||
config: ClientAppConfigView | null,
|
||||
): AuthLoginMethod[] {
|
||||
const methods: AuthLoginMethod[] = [];
|
||||
if (config?.smsAuthEnabled) {
|
||||
methods.push('phone');
|
||||
}
|
||||
if (config?.wechatEnabled) {
|
||||
methods.push('wechat');
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
function mapThemeMode(tag: string): CustomWorldLibraryEntry['themeMode'] {
|
||||
switch (tag) {
|
||||
case 'Martial':
|
||||
return 'martial';
|
||||
case 'Arcane':
|
||||
return 'arcane';
|
||||
case 'Machina':
|
||||
return 'machina';
|
||||
case 'Tide':
|
||||
return 'tide';
|
||||
case 'Rift':
|
||||
return 'rift';
|
||||
default:
|
||||
return 'mythic';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson<T>(jsonText: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(jsonText) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSnapshotRow(row: SnapshotView): SavedGameSnapshot {
|
||||
return {
|
||||
version: Number(row.version),
|
||||
savedAt: bigintToIso(row.savedAtMs) ?? new Date(0).toISOString(),
|
||||
gameState: parseJson(row.gameStateJson, {}),
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStoryJson
|
||||
? parseJson(row.currentStoryJson, null)
|
||||
: null,
|
||||
} as SavedGameSnapshot;
|
||||
}
|
||||
|
||||
export function mapRuntimeSettings(row: RuntimeSettingsView | null): RuntimeSettings {
|
||||
return {
|
||||
musicVolume: row ? Number(row.musicVolume) : 0.42,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapProfileDashboard(
|
||||
row: ProfileDashboardView | null,
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: row ? bigintToNumber(row.walletBalance) : 0,
|
||||
totalPlayTimeMs: row ? bigintToNumber(row.totalPlayTimeMs) : 0,
|
||||
playedWorldCount: row ? Number(row.playedWorldCount) : 0,
|
||||
updatedAt: row ? bigintToIso(row.updatedAtMs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWalletLedgerEntry(
|
||||
row: ProfileWalletLedgerView,
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
amountDelta: bigintToNumber(row.amountDelta),
|
||||
balanceAfter: bigintToNumber(row.balanceAfter),
|
||||
sourceType: 'snapshot_sync',
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPlayedWorldEntry(
|
||||
row: ProfilePlayedWorldView,
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId ?? null,
|
||||
profileId: row.profileId ?? null,
|
||||
worldType: row.worldType ?? null,
|
||||
worldTitle: row.worldTitle,
|
||||
worldSubtitle: row.worldSubtitle,
|
||||
firstPlayedAt: bigintToIso(row.firstPlayedAtMs) ?? new Date(0).toISOString(),
|
||||
lastPlayedAt: bigintToIso(row.lastPlayedAtMs) ?? new Date(0).toISOString(),
|
||||
lastObservedPlayTimeMs: bigintToNumber(row.lastObservedPlayTimeMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrowseHistoryEntry(
|
||||
row: PlatformBrowseHistoryView,
|
||||
): PlatformBrowseHistoryEntry {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
visitedAt: bigintToIso(row.visitedAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapCustomWorldLibraryEntry(
|
||||
row: CustomWorldProfileView,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
profile: parseJson<CustomWorldProfile>(row.payloadJson, {
|
||||
id: row.profileId,
|
||||
settingText: '',
|
||||
name: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summary: row.summaryText,
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
dimensions: [],
|
||||
coreStats: [],
|
||||
derivedStats: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
} as unknown as CustomWorldProfile),
|
||||
visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft',
|
||||
publishedAt: bigintToIso(row.publishedAtMs),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
playableNpcCount: Number(row.playableNpcCount),
|
||||
landmarkCount: Number(row.landmarkCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGalleryCard(
|
||||
row: CustomWorldGalleryCardView,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft',
|
||||
publishedAt: bigintToIso(row.publishedAtMs),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
playableNpcCount: Number(row.playableNpcCount),
|
||||
landmarkCount: Number(row.landmarkCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPublishedProfile(
|
||||
row: PublishedCustomWorldProfileView,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return mapCustomWorldLibraryEntry({
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
payloadJson: row.payloadJson,
|
||||
visibility: row.visibility,
|
||||
publishedAtMs: row.publishedAtMs,
|
||||
updatedAtMs: row.updatedAtMs,
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc,
|
||||
themeMode: row.themeMode,
|
||||
playableNpcCount: row.playableNpcCount,
|
||||
landmarkCount: row.landmarkCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCustomWorldSession(
|
||||
row: CustomWorldSessionView,
|
||||
) {
|
||||
return {
|
||||
...parseJson<Record<string, unknown>>(row.payloadJson, {}),
|
||||
sessionId: row.sessionId,
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAuditLogEntry(row: AuthAuditLogView): AuthAuditLogEntry {
|
||||
const eventType = row.eventType as AuthAuditLogEntry['eventType'];
|
||||
return {
|
||||
id: String(bigintToNumber(row.id)),
|
||||
eventType,
|
||||
title: row.eventType,
|
||||
detail: row.detail,
|
||||
ipMasked: row.ip ?? null,
|
||||
userAgent: row.userAgent ?? null,
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function maskIpAddress(ip: string | null | undefined) {
|
||||
if (!ip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ip.includes(':')) {
|
||||
const parts = ip.split(':').filter(Boolean);
|
||||
if (parts.length <= 2) {
|
||||
return ip;
|
||||
}
|
||||
return `${parts.slice(0, 2).join(':')}::*`;
|
||||
}
|
||||
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) {
|
||||
return ip;
|
||||
}
|
||||
return `${parts[0]}.${parts[1]}.*.*`;
|
||||
}
|
||||
|
||||
function buildSessionClientLabel(session: {
|
||||
clientType: string;
|
||||
userAgent: string | null | undefined;
|
||||
}) {
|
||||
const userAgent = session.userAgent?.toLowerCase() || '';
|
||||
if (
|
||||
userAgent.includes('mobile') ||
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone')
|
||||
) {
|
||||
return '移动端浏览器';
|
||||
}
|
||||
if (session.clientType === 'web' || session.clientType === 'browser') {
|
||||
return '网页端浏览器';
|
||||
}
|
||||
return session.clientType || '未知设备';
|
||||
}
|
||||
|
||||
export function mapAuthSession(
|
||||
row: AuthSessionView,
|
||||
options: {
|
||||
currentSessionId?: string;
|
||||
} = {},
|
||||
): AuthSessionSummary {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
clientType: row.clientType,
|
||||
clientLabel: buildSessionClientLabel({
|
||||
clientType: row.clientType,
|
||||
userAgent: row.userAgent ?? null,
|
||||
}),
|
||||
userAgent: row.userAgent ?? null,
|
||||
ipMasked: maskIpAddress(row.ip ?? null),
|
||||
isCurrent:
|
||||
options.currentSessionId?.trim() === row.sessionId ||
|
||||
Boolean(row.isCurrent),
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? '',
|
||||
lastSeenAt: bigintToIso(row.lastSeenAtMs) ?? '',
|
||||
expiresAt: bigintToIso(row.expiresAtMs) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAuthRiskBlock(row: AuthRiskBlockView): AuthRiskBlockSummary {
|
||||
const scopeType = enumTag(row.scopeType) === 'Phone' ? 'phone' : 'ip';
|
||||
const expiresAtMs = bigintToNumber(row.expiresAtMs);
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.floor((expiresAtMs - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
return {
|
||||
scopeType,
|
||||
title: scopeType === 'phone' ? '手机号保护中' : 'IP 保护中',
|
||||
detail:
|
||||
scopeType === 'phone'
|
||||
? `当前手机号因 ${row.reason} 被暂时保护`
|
||||
: `当前连接因 ${row.reason} 被暂时保护`,
|
||||
expiresAt: bigintToIso(row.expiresAtMs) ?? '',
|
||||
remainingSeconds,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user